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 2050b526 [integrations][java] Share POJO JSON Schema generation across
Ollama, Gemini, Bedrock and watsonx (#1120)
2050b526 is described below
commit 2050b5263e72541594cd798c2c7e52233b7ee98b
Author: Weiqing Yang <[email protected]>
AuthorDate: Tue Sep 15 02:31:31 2026 -0700
[integrations][java] Share POJO JSON Schema generation across Ollama,
Gemini, Bedrock and watsonx (#1120)
Generated-by: Claude Code 2.1.272 (Claude Opus 5)
---
dist/pom.xml | 5 +
integrations/chat-models/bedrock/pom.xml | 9 +-
.../bedrock/BedrockChatModelConnection.java | 46 +----
.../bedrock/BedrockChatModelConnectionTest.java | 56 ++----
.../chat-models/{ollama => common}/pom.xml | 17 +-
.../chatmodels/common/PojoJsonSchemaGenerator.java | 83 +++++++++
.../common/PojoJsonSchemaGeneratorTest.java | 196 +++++++++++++++++++++
integrations/chat-models/gemini/pom.xml | 9 +-
.../gemini/GeminiChatModelConnection.java | 65 ++-----
.../gemini/GeminiChatModelConnectionTest.java | 47 +----
integrations/chat-models/ollama/pom.xml | 9 +-
.../ollama/OllamaChatModelConnection.java | 48 ++---
.../ollama/OllamaChatModelConnectionTest.java | 109 ++++++------
integrations/chat-models/pom.xml | 1 +
integrations/chat-models/watsonx/pom.xml | 9 +-
.../watsonx/WatsonxChatModelConnection.java | 50 ++----
.../watsonx/WatsonxChatModelConnectionTest.java | 92 +++++-----
17 files changed, 473 insertions(+), 378 deletions(-)
diff --git a/dist/pom.xml b/dist/pom.xml
index 7cb74295..df52c7a3 100644
--- a/dist/pom.xml
+++ b/dist/pom.xml
@@ -62,6 +62,11 @@ under the License.
<artifactId>flink-agents-runtime</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.flink</groupId>
+
<artifactId>flink-agents-integrations-chat-models-common</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-agents-integrations-chat-models-ollama</artifactId>
diff --git a/integrations/chat-models/bedrock/pom.xml
b/integrations/chat-models/bedrock/pom.xml
index eb88c88a..62bd09bf 100644
--- a/integrations/chat-models/bedrock/pom.xml
+++ b/integrations/chat-models/bedrock/pom.xml
@@ -44,15 +44,16 @@ under the License.
<version>${aws.sdk.version}</version>
</dependency>
- <!-- Versions managed by the victools BOM imported in the root pom. -->
<dependency>
- <groupId>com.github.victools</groupId>
- <artifactId>jsonschema-generator</artifactId>
+ <groupId>org.apache.flink</groupId>
+
<artifactId>flink-agents-integrations-chat-models-common</artifactId>
+ <version>${project.version}</version>
</dependency>
+ <!-- Versions managed by the victools BOM imported in the root pom. -->
<dependency>
<groupId>com.github.victools</groupId>
- <artifactId>jsonschema-module-jackson</artifactId>
+ <artifactId>jsonschema-generator</artifactId>
</dependency>
</dependencies>
diff --git
a/integrations/chat-models/bedrock/src/main/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnection.java
b/integrations/chat-models/bedrock/src/main/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnection.java
index 62cc250c..29989ab6 100644
---
a/integrations/chat-models/bedrock/src/main/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnection.java
+++
b/integrations/chat-models/bedrock/src/main/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnection.java
@@ -22,12 +22,6 @@ 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.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.module.jackson.JacksonModule;
-import com.github.victools.jsonschema.module.jackson.JacksonOption;
import org.apache.flink.agents.api.RetryExecutor;
import org.apache.flink.agents.api.chat.messages.ChatMessage;
import org.apache.flink.agents.api.chat.messages.MessageRole;
@@ -36,6 +30,7 @@ import org.apache.flink.agents.api.resource.ResourceContext;
import org.apache.flink.agents.api.resource.ResourceDescriptor;
import org.apache.flink.agents.api.tools.Tool;
import org.apache.flink.agents.api.tools.ToolMetadata;
+import
org.apache.flink.agents.integrations.chatmodels.common.PojoJsonSchemaGenerator;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.core.SdkNumber;
import software.amazon.awssdk.core.document.Document;
@@ -66,7 +61,6 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
-import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -388,24 +382,8 @@ public class BedrockChatModelConnection extends
BaseChatModelConnection {
.build();
}
- // Derives the JSON schema from a POJO class. Every setting below
addresses a concrete way the
- // generated schema otherwise fails to constrain generation:
- //
- // - DRAFT_2020_12 is the dialect Bedrock validates a schema against, so
the schema
- // declares it rather than the generator's older default.
- // - The PLAIN_JSON preset keeps generation to fields. Without a preset,
getters surface as
- // properties of their own, named after the accessor call, e.g.
"getSummary()".
- // - The required check marks every field required except an Optional
one. The default marks
- // nothing required, which lets a model omit fields at will, 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, and
- // list enum constants mapped by @JsonProperty or by a @JsonValue
method under the values
- // Jackson reads. The response is read back into the same class with
an ObjectMapper, so a
- // property that @JsonProperty renames or @JsonIgnore drops, and a
mapped enum constant,
- // have to appear in the schema as the mapper reads them, or a
response that satisfies the
- // schema still fails to deserialize. An enum annotating only some
constants falls back to
- // Java names for all of them, so its annotated constants do not read
back. The two enum
- // options change only the listed values: the required set stays the
one configured above.
+ // Derives the JSON schema from a POJO class with the shared generator,
adding no option. The
+ // shared schema declares draft 2020-12, the dialect Bedrock validates a
schema against.
//
// A Map's value schema is deliberately left underived. Bedrock accepts
additionalProperties
// only as false, and rejects a schema that carries it as a subschema, so
typing map values
@@ -413,21 +391,11 @@ public class BedrockChatModelConnection extends
BaseChatModelConnection {
// bare object.
//
// A self-referencing class derives its own field as a reference back to
the schema root,
- // whatever the required check says. Bedrock does not accept a recursive
schema and rejects the
- // request before the model runs, so declaring the field Optional does not
rescue it; only
- // flattening the recursion does.
+ // whatever the shared generator's required check says. Bedrock does not
accept a recursive
+ // schema and rejects the request before the model runs, so declaring the
field Optional does
+ // not rescue it; only flattening the recursion does.
private static JsonNode toNativeSchema(Class<?> schemaClass) {
- SchemaGeneratorConfigBuilder configBuilder =
- new SchemaGeneratorConfigBuilder(
- SchemaVersion.DRAFT_2020_12,
OptionPreset.PLAIN_JSON)
- .with(
- new JacksonModule(
-
JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY,
-
JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE));
- configBuilder
- .forFields()
- .withRequiredCheck(field ->
!Optional.class.equals(field.getRawMember().getType()));
- return new
SchemaGenerator(configBuilder.build()).generateSchema(schemaClass);
+ return PojoJsonSchemaGenerator.generate(schemaClass);
}
private static boolean isRetryable(Exception e) {
diff --git
a/integrations/chat-models/bedrock/src/test/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnectionTest.java
b/integrations/chat-models/bedrock/src/test/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnectionTest.java
index 59214174..334c94a7 100644
---
a/integrations/chat-models/bedrock/src/test/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnectionTest.java
+++
b/integrations/chat-models/bedrock/src/test/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnectionTest.java
@@ -18,7 +18,6 @@
package org.apache.flink.agents.integrations.chatmodels.bedrock;
-import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.databind.JsonNode;
@@ -285,31 +284,23 @@ class BedrockChatModelConnectionTest {
private static final ObjectMapper SCHEMA_MAPPER = new ObjectMapper();
- /**
- * 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.
- */
+ /** Output schema fixture for tests that need a POJO class schema of any
shape. */
public static class Profile {
- @JsonProperty("full_name")
public String name;
-
- @JsonIgnore public String secret;
-
- public int age;
}
- /**
- * Output schema fixture shaped to expose what the derived schema
constrains: which fields a
- * response must carry, and how a map field is rendered.
- */
+ /** Output schema fixture with a map field whose values carry a type. */
public static class Reading {
- public int score;
+ public Map<String, Integer> counts;
+ }
- public Optional<String> note;
+ /** Output schema fixture whose fields are declared in an order other than
alphabetical. */
+ public static class Task {
+ public String title;
- public Map<String, Integer> counts;
+ public int priority;
+
+ public boolean archived;
}
/**
@@ -499,23 +490,19 @@ class BedrockChatModelConnectionTest {
}
@Test
- @DisplayName("the derived schema names properties the way Jackson
deserializes them")
- void testDerivedSchemaFollowsJacksonPropertyNames() throws Exception {
+ @DisplayName("the derived schema lists properties in the order the class
declares them")
+ void testDerivedSchemaKeepsDeclarationOrder() throws Exception {
ConverseRequest request =
connection()
.buildRequest(
List.of(ChatMessage.user("hello")),
null,
params(CAPABLE_MODEL),
- Profile.class);
+ Task.class);
- // The response is read back into the same class, so a property that
@JsonProperty renames
- // or @JsonIgnore drops has to be stated under the name the mapper
reads. Derived without
- // Jackson's property model the schema instead names "name" and
demands the ignored
- // "secret", constraining the model to a document the mapper then
refuses.
assertThat(nativeSchema(request).path("properties").fieldNames())
.toIterable()
- .containsExactlyInAnyOrder("full_name", "age");
+ .containsExactly("title", "priority", "archived");
}
@Test
@@ -546,8 +533,8 @@ class BedrockChatModelConnectionTest {
}
@Test
- @DisplayName("the derived schema declares its dialect, requires fields,
and leaves maps bare")
- void testDerivedSchemaConstrainsTheResponse() throws Exception {
+ @DisplayName("the derived schema leaves map values untyped")
+ void testDerivedSchemaLeavesMapsBare() throws Exception {
ConverseRequest request =
connection()
.buildRequest(
@@ -557,23 +544,12 @@ class BedrockChatModelConnectionTest {
Reading.class);
JsonNode schema = nativeSchema(request);
- // Left to itself the generator marks nothing required, so an empty
document satisfies a
- // schema whose whole purpose is to constrain the response. An
Optional field is the one a
- // caller declared omissible, so it stays out of the required set.
- List<String> required = new ArrayList<>();
- schema.path("required").forEach(entry -> required.add(entry.asText()));
- assertThat(required).containsExactlyInAnyOrder("score", "counts");
-
// A map derives as a bare object. Typing its values renders them under
// additionalProperties, which Bedrock accepts only as false and
rejects as a subschema, so
// the value type is left off rather than putting the request outside
the accepted subset.
assertThat(schema.path("properties").path("counts").path("type").asText())
.isEqualTo("object");
assertThat(schema.findValues("additionalProperties")).isEmpty();
-
- // The dialect is stated rather than left on the generator's older
default.
- assertThat(schema.path("$schema").asText())
- .isEqualTo("https://json-schema.org/draft/2020-12/schema");
}
@Test
diff --git a/integrations/chat-models/ollama/pom.xml
b/integrations/chat-models/common/pom.xml
similarity index 79%
copy from integrations/chat-models/ollama/pom.xml
copy to integrations/chat-models/common/pom.xml
index 49638efd..24c39026 100644
--- a/integrations/chat-models/ollama/pom.xml
+++ b/integrations/chat-models/common/pom.xml
@@ -27,21 +27,14 @@ under the License.
<relativePath>../pom.xml</relativePath>
</parent>
- <artifactId>flink-agents-integrations-chat-models-ollama</artifactId>
- <name>Flink Agents : Integrations: Chat Models: Ollama</name>
+ <artifactId>flink-agents-integrations-chat-models-common</artifactId>
+ <name>Flink Agents : Integrations: Chat Models: Common</name>
<packaging>jar</packaging>
<dependencies>
<dependency>
- <groupId>org.apache.flink</groupId>
- <artifactId>flink-agents-api</artifactId>
- <version>${project.version}</version>
- </dependency>
-
- <dependency>
- <groupId>io.github.ollama4j</groupId>
- <artifactId>ollama4j</artifactId>
- <version>${ollama4j.version}</version>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-databind</artifactId>
</dependency>
<!-- Versions managed by the victools BOM imported in the root pom. -->
@@ -56,4 +49,4 @@ under the License.
</dependency>
</dependencies>
-</project>
\ No newline at end of file
+</project>
diff --git
a/integrations/chat-models/common/src/main/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGenerator.java
b/integrations/chat-models/common/src/main/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGenerator.java
new file mode 100644
index 00000000..e36322e9
--- /dev/null
+++
b/integrations/chat-models/common/src/main/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGenerator.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.integrations.chatmodels.common;
+
+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 java.util.Optional;
+
+/** Derives the JSON Schema that chat model connections send as a native
output schema. */
+public final class PojoJsonSchemaGenerator {
+
+ private PojoJsonSchemaGenerator() {}
+
+ // Every fixed setting below addresses a concrete way the generated schema
otherwise misstates
+ // the contract of the class a caller deserializes the response into:
+ //
+ // - DRAFT_2020_12 is the dialect pydantic generates on the Python side,
so a schema derived
+ // from a Java class states the same contract in the same dialect.
+ // - The PLAIN_JSON preset keeps generation to fields. Under the default
FULL_DOCUMENTATION
+ // preset, getters also surface as properties of their own, named
after the accessor call,
+ // e.g. "getDerived()".
+ // - Sorting fields before methods and applying no further comparison
leaves properties in
+ // declaration order, which is the order pydantic emits.
+ // - The required check marks every field required except an Optional
one. The default marks
+ // nothing required, which lets a model omit fields at will, while
marking everything
+ // required would force the fields a caller declared omissible.
+ // - The Jackson module names properties the way an ObjectMapper reads
them, honoring
+ // @JsonProperty renames and dropping @JsonIgnore fields, which the
required check would
+ // otherwise mark required. It lists enum constants mapped by
@JsonProperty or by a
+ // @JsonValue method under those mapped values. A property or enum
constant stated under
+ // any other name makes a response that satisfies the schema fail to
deserialize. An enum
+ // annotating only some constants falls back to Java names for all of
them, so its
+ // annotated constants do not read back. RESPECT_JSONPROPERTY_REQUIRED
stays off, so
+ // @JsonProperty(required = true) does not widen the required set
configured here.
+
+ /**
+ * Generates the JSON Schema for {@code type}.
+ *
+ * @param type the class a response is deserialized into
+ * @param options additional generator options enabled on top of the fixed
settings
+ * @return the schema document
+ */
+ public static ObjectNode generate(Class<?> type, Option... options) {
+ SchemaGeneratorConfigBuilder builder =
+ new SchemaGeneratorConfigBuilder(
+ SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON);
+ for (Option option : options) {
+ builder.with(option);
+ }
+ builder.with(
+ new JacksonModule(
+ JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY,
+ JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE));
+ builder.forTypesInGeneral()
+
.withPropertySorter(PropertySortUtils.SORT_PROPERTIES_FIELDS_BEFORE_METHODS);
+ builder.forFields()
+ .withRequiredCheck(field ->
!Optional.class.equals(field.getRawMember().getType()));
+ return new SchemaGenerator(builder.build()).generateSchema(type);
+ }
+}
diff --git
a/integrations/chat-models/common/src/test/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGeneratorTest.java
b/integrations/chat-models/common/src/test/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGeneratorTest.java
new file mode 100644
index 00000000..838336a7
--- /dev/null
+++
b/integrations/chat-models/common/src/test/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGeneratorTest.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.integrations.chatmodels.common;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.victools.jsonschema.generator.Option;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Unit tests for {@link PojoJsonSchemaGenerator}. */
+class PojoJsonSchemaGeneratorTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /**
+ * Output schema fixture shaped to expose the generation settings: fields
declared in a
+ * non-alphabetical order, one {@code Optional} field, and a getter with
no backing field.
+ */
+ public static class Report {
+ public String summary;
+ public Map<String, Integer> counts;
+ public Optional<String> note;
+ public int total;
+
+ public String getDerived() {
+ return summary + 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;
+ }
+
+ /**
+ * Output schema fixture whose enum constants are deserialized from values
other than their
+ * names, one through {@code @JsonProperty} on the constants and one
through a
+ * {@code @JsonValue} method.
+ */
+ public static class Ticket {
+ public Status status;
+
+ public Phase phase;
+ }
+
+ public enum Status {
+ @JsonProperty("in-progress")
+ IN_PROGRESS,
+ @JsonProperty("done")
+ DONE
+ }
+
+ public enum Phase {
+ STARTED("started"),
+ FINISHED("finished");
+
+ private final String wire;
+
+ Phase(String wire) {
+ this.wire = wire;
+ }
+
+ @JsonValue
+ public String wire() {
+ return wire;
+ }
+ }
+
+ @Test
+ @DisplayName("The generated schema names properties the way Jackson
deserializes them")
+ void namesPropertiesTheWayJacksonReadsThem() {
+ JsonNode schema = PojoJsonSchemaGenerator.generate(Profile.class);
+
+ // A caller deserializing the response into this class accepts the
renamed property and
+ // rejects the Java field name, and discards an ignored property that
the schema would
+ // otherwise state as required and so force a value for.
+ assertThat(fieldNames(schema.path("properties")))
+ .containsExactlyInAnyOrder("full_name", "age");
+ }
+
+ @Test
+ @DisplayName("The generated schema lists enum constants the way Jackson
deserializes them")
+ void listsEnumConstantsByTheirJacksonWireValues() throws Exception {
+ JsonNode properties =
PojoJsonSchemaGenerator.generate(Ticket.class).path("properties");
+
+ // Every listed value is one the model may emit, so each has to
deserialize into the enum.
+ // Listed by constant name instead, a plain mapper refuses every value
the schema allows.
+ List<Status> statuses = new ArrayList<>();
+ for (JsonNode value : properties.path("status").path("enum")) {
+ statuses.add(MAPPER.treeToValue(value, Status.class));
+ }
+ assertThat(statuses).containsExactlyInAnyOrder(Status.values());
+
+ List<Phase> phases = new ArrayList<>();
+ for (JsonNode value : properties.path("phase").path("enum")) {
+ phases.add(MAPPER.treeToValue(value, Phase.class));
+ }
+ assertThat(phases).containsExactlyInAnyOrder(Phase.values());
+ }
+
+ @Test
+ @DisplayName("The generated schema requires every field the caller did not
make optional")
+ void requiresEveryFieldExceptOptional() {
+ JsonNode schema = PojoJsonSchemaGenerator.generate(Report.class);
+
+ // Without a required set the model may omit fields the caller
declared, and with an
+ // all-inclusive one it must invent a value for the field the caller
made omissible.
+ assertThat(textValues(schema.path("required")))
+ .containsExactlyInAnyOrder("summary", "counts", "total");
+ }
+
+ @Test
+ @DisplayName("The generated schema keeps declaration order and emits no
getter property")
+ void keepsDeclarationOrderWithoutGetters() {
+ JsonNode schema = PojoJsonSchemaGenerator.generate(Report.class);
+
+ // The fixture declares its fields out of alphabetical order, so an
alphabetical sort
+ // reorders them, and a getter surfacing as a property adds an entry.
+ assertThat(fieldNames(schema.path("properties")))
+ .containsExactly("summary", "counts", "note", "total");
+ }
+
+ @Test
+ @DisplayName("The generated schema declares JSON Schema draft 2020-12")
+ void declaresDraft202012() {
+ JsonNode schema = PojoJsonSchemaGenerator.generate(Report.class);
+
+ assertThat(schema.path("$schema").asText())
+ .isEqualTo("https://json-schema.org/draft/2020-12/schema");
+ }
+
+ @Test
+ @DisplayName("The generated schema applies an option only when it is
passed in")
+ void appliesAnOptionOnlyWhenPassedIn() {
+ JsonNode withOption =
+ PojoJsonSchemaGenerator.generate(
+ Report.class,
Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES)
+ .path("properties")
+ .path("counts");
+ JsonNode withoutOption =
+
PojoJsonSchemaGenerator.generate(Report.class).path("properties").path("counts");
+
+ // The map's value schema comes from the option: typed when it is
passed, absent when not.
+
assertThat(withOption.path("additionalProperties").path("type").asText())
+ .isEqualTo("integer");
+ assertThat(withoutOption.has("additionalProperties")).isFalse();
+ }
+
+ private static List<String> fieldNames(JsonNode node) {
+ List<String> names = new ArrayList<>();
+ node.fieldNames().forEachRemaining(names::add);
+ return names;
+ }
+
+ private static List<String> textValues(JsonNode array) {
+ List<String> values = new ArrayList<>();
+ array.forEach(value -> values.add(value.asText()));
+ return values;
+ }
+}
diff --git a/integrations/chat-models/gemini/pom.xml
b/integrations/chat-models/gemini/pom.xml
index 45516d3f..81587b63 100644
--- a/integrations/chat-models/gemini/pom.xml
+++ b/integrations/chat-models/gemini/pom.xml
@@ -44,15 +44,16 @@ under the License.
<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>
+ <groupId>org.apache.flink</groupId>
+
<artifactId>flink-agents-integrations-chat-models-common</artifactId>
+ <version>${project.version}</version>
</dependency>
+ <!-- Versions managed by the victools BOM imported in the root pom. -->
<dependency>
<groupId>com.github.victools</groupId>
- <artifactId>jsonschema-module-jackson</artifactId>
+ <artifactId>jsonschema-generator</artifactId>
</dependency>
</dependencies>
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 b4f128ab..3c34c431 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
@@ -23,13 +23,6 @@ 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;
@@ -47,6 +40,7 @@ import
org.apache.flink.agents.api.chat.model.BaseChatModelConnection;
import org.apache.flink.agents.api.resource.ResourceContext;
import org.apache.flink.agents.api.resource.ResourceDescriptor;
import org.apache.flink.agents.api.tools.ToolMetadata;
+import
org.apache.flink.agents.integrations.chatmodels.common.PojoJsonSchemaGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -401,17 +395,16 @@ public class GeminiChatModelConnection extends
BaseChatModelConnection {
// 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:
+ // without reporting which, so the shared schema and the options added to
it are checked
+ // 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()".
+ // - The shared schema is DRAFT_2020_12, 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.
// - 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.
+ // additionalProperties keyword carrying the declared value type.
Without it, 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
@@ -419,44 +412,20 @@ public class GeminiChatModelConnection extends
BaseChatModelConnection {
// 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.
+ // - Properties follow 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 the generator never emits.
//
// 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);
+ ObjectNode schema =
+ PojoJsonSchemaGenerator.generate(
+ schemaClass,
+ Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES,
+ Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT);
stripRefSiblings(schema);
return schema;
}
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 222db434..1cadda4e 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,7 +18,6 @@
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;
@@ -113,8 +112,7 @@ class GeminiChatModelConnectionTest {
/**
* 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.
+ * alphabetical order, and {@code counts} is a map whose values carry a
type.
*/
public static class Report {
public String summary;
@@ -123,21 +121,6 @@ class GeminiChatModelConnectionTest {
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;
@@ -800,20 +783,6 @@ class GeminiChatModelConnectionTest {
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 {
@@ -861,20 +830,6 @@ class GeminiChatModelConnectionTest {
.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() {
diff --git a/integrations/chat-models/ollama/pom.xml
b/integrations/chat-models/ollama/pom.xml
index 49638efd..6229e84a 100644
--- a/integrations/chat-models/ollama/pom.xml
+++ b/integrations/chat-models/ollama/pom.xml
@@ -44,15 +44,16 @@ under the License.
<version>${ollama4j.version}</version>
</dependency>
- <!-- Versions managed by the victools BOM imported in the root pom. -->
<dependency>
- <groupId>com.github.victools</groupId>
- <artifactId>jsonschema-generator</artifactId>
+ <groupId>org.apache.flink</groupId>
+
<artifactId>flink-agents-integrations-chat-models-common</artifactId>
+ <version>${project.version}</version>
</dependency>
+ <!-- Versions managed by the victools BOM imported in the root pom. -->
<dependency>
<groupId>com.github.victools</groupId>
- <artifactId>jsonschema-module-jackson</artifactId>
+ <artifactId>jsonschema-generator</artifactId>
</dependency>
</dependencies>
diff --git
a/integrations/chat-models/ollama/src/main/java/org/apache/flink/agents/integrations/chatmodels/ollama/OllamaChatModelConnection.java
b/integrations/chat-models/ollama/src/main/java/org/apache/flink/agents/integrations/chatmodels/ollama/OllamaChatModelConnection.java
index be42f834..28857a3b 100644
---
a/integrations/chat-models/ollama/src/main/java/org/apache/flink/agents/integrations/chatmodels/ollama/OllamaChatModelConnection.java
+++
b/integrations/chat-models/ollama/src/main/java/org/apache/flink/agents/integrations/chatmodels/ollama/OllamaChatModelConnection.java
@@ -22,12 +22,6 @@ import com.fasterxml.jackson.core.type.TypeReference;
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 io.github.ollama4j.exceptions.RoleNotFoundException;
import io.github.ollama4j.models.chat.*;
import io.github.ollama4j.models.request.OllamaChatEndpointCaller;
@@ -39,6 +33,7 @@ import
org.apache.flink.agents.api.chat.model.BaseChatModelConnection;
import org.apache.flink.agents.api.resource.ResourceContext;
import org.apache.flink.agents.api.resource.ResourceDescriptor;
import org.apache.flink.agents.api.tools.Tool;
+import
org.apache.flink.agents.integrations.chatmodels.common.PojoJsonSchemaGenerator;
import java.util.*;
import java.util.stream.Collectors;
@@ -329,29 +324,18 @@ public class OllamaChatModelConnection extends
BaseChatModelConnection {
return chatRequest;
}
- // Derives the JSON schema Ollama's format field expects from a POJO
class. Every setting below
- // addresses a concrete way the generated schema otherwise fails to
constrain generation:
+ // Derives the JSON schema Ollama's format field expects from a POJO
class. The schema comes
+ // from the shared generator with one option added:
//
- // - DRAFT_2020_12 is the draft pydantic generates on the Python side,
so a schema derived
- // from a Java class states the same contract in the same dialect.
- // - The PLAIN_JSON preset keeps generation to fields. Without a preset,
getters surface as
- // properties of their own, named after the accessor call, e.g.
"getSummary()".
// - MAP_VALUES_AS_ADDITIONAL_PROPERTIES gives a Map its value schema.
Without it the map
// admits any value, and a model does emit values that the declared
value type then fails
// to deserialize.
- // - Sorting fields before methods and applying no further comparison
leaves properties in
- // declaration order. Ollama's grammar fixes generation order to the
order the schema
- // declares its properties, so the default alphabetical order would
condition generation on
- // an order the class does not read in.
- // - The required check marks every field required except an Optional
one. The default marks
- // nothing required, which lets a model omit fields at will, 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.
- // It is applied with no JacksonOption, so it contributes property
naming and visibility
- // only: the required set and the property order stay the ones
configured below.
+ //
+ // When Ollama runs a model on llama.cpp, the grammar built from the
schema generates the
+ // required properties in the order the schema declares them, followed by
the optional ones,
+ // so the declaration order the shared schema keeps is the order the model
fills in a class's
+ // required fields. An alphabetical order would condition generation on an
order the class
+ // does not declare.
//
// Two settings are deliberately absent:
//
@@ -364,18 +348,8 @@ public class OllamaChatModelConnection extends
BaseChatModelConnection {
// $defs, so enabling it would silently unconstrain a common shape to
rescue a rare one. A
// recursive type instead fails loudly, with HTTP 400 from the server.
private static ObjectNode toNativeFormat(Class<?> schemaClass) {
- SchemaGeneratorConfigBuilder configBuilder =
- new SchemaGeneratorConfigBuilder(
- SchemaVersion.DRAFT_2020_12,
OptionPreset.PLAIN_JSON)
- .with(Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES)
- .with(new JacksonModule());
- configBuilder
- .forTypesInGeneral()
-
.withPropertySorter(PropertySortUtils.SORT_PROPERTIES_FIELDS_BEFORE_METHODS);
- configBuilder
- .forFields()
- .withRequiredCheck(field ->
!Optional.class.equals(field.getRawMember().getType()));
- return new
SchemaGenerator(configBuilder.build()).generateSchema(schemaClass);
+ return PojoJsonSchemaGenerator.generate(
+ schemaClass, Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES);
}
/**
diff --git
a/integrations/chat-models/ollama/src/test/java/org/apache/flink/agents/integrations/chatmodels/ollama/OllamaChatModelConnectionTest.java
b/integrations/chat-models/ollama/src/test/java/org/apache/flink/agents/integrations/chatmodels/ollama/OllamaChatModelConnectionTest.java
index f8b70633..3d8a0721 100644
---
a/integrations/chat-models/ollama/src/test/java/org/apache/flink/agents/integrations/chatmodels/ollama/OllamaChatModelConnectionTest.java
+++
b/integrations/chat-models/ollama/src/test/java/org/apache/flink/agents/integrations/chatmodels/ollama/OllamaChatModelConnectionTest.java
@@ -17,9 +17,10 @@
*/
package org.apache.flink.agents.integrations.chatmodels.ollama;
-import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.ollama4j.models.chat.OllamaChatRequest;
import io.github.ollama4j.tools.Tools;
import org.apache.flink.agents.api.chat.messages.ChatMessage;
@@ -41,7 +42,6 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@@ -54,39 +54,46 @@ class OllamaChatModelConnectionTest {
private static final ResourceContext NOOP =
ResourceContext.fromGetResource((a, b) -> null);
- private static final String DRAFT_2020_12 =
"https://json-schema.org/draft/2020-12/schema";
+ private static final ObjectMapper MAPPER = new ObjectMapper();
- /**
- * Output schema fixture shaped to expose the schema-generation settings.
- *
- * <p>Fields are declared out of alphabetical order, {@code counts} is a
map whose values carry
- * a type, {@code note} is the only optional field, and {@code getDerived}
is a getter backed by
- * no field.
- */
+ /** Output schema fixture with a plain field and a map whose values carry
a type. */
public static class Report {
public String summary;
public Map<String, Integer> counts;
- public Optional<String> note;
- public int total;
-
- public String getDerived() {
- return summary + 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.
+ * Output schema fixture whose enum constants are deserialized from values
other than their
+ * names, one through {@code @JsonProperty} on the constants and one
through a
+ * {@code @JsonValue} method.
*/
- public static class Profile {
- @JsonProperty("full_name")
- public String name;
+ public static class Ticket {
+ public Status status;
- @JsonIgnore public String secret;
+ public Phase phase;
+ }
- public int age;
+ public enum Status {
+ @JsonProperty("in-progress")
+ IN_PROGRESS,
+ @JsonProperty("done")
+ DONE
+ }
+
+ public enum Phase {
+ STARTED("started"),
+ FINISHED("finished");
+
+ private final String wire;
+
+ Phase(String wire) {
+ this.wire = wire;
+ }
+
+ @JsonValue
+ public String wire() {
+ return wire;
+ }
}
private static OllamaChatModelConnection connection() {
@@ -201,21 +208,13 @@ class OllamaChatModelConnectionTest {
}
@Test
- @DisplayName("The generated schema constrains draft, property order, map
values and required")
- void generatedSchemaShapeIsConstraining() {
+ @DisplayName("The generated schema gives map values their own schema")
+ void generatedSchemaGivesMapValuesTheirSchema() {
OllamaChatRequest request =
connection()
.buildRequest(userMessage(), List.of(),
params("qwen3:4b"), Report.class);
JsonNode schema = (JsonNode) request.getFormat();
- // Ollama fixes generation order to the order the schema declares its
properties, so the
- // emitted order has to follow the class rather than the alphabet. A
getter backed by no
- // field must not surface as a property of its own.
- assertThat(schema.path("$schema").asText()).isEqualTo(DRAFT_2020_12);
- assertThat(schema.path("properties").fieldNames())
- .toIterable()
- .containsExactly("summary", "counts", "note", "total");
-
// A map without a value schema admits any value, which the model does
take up and which
// then fails to deserialize into the declared type.
assertThat(schema.path("properties").path("counts").path("additionalProperties").isObject())
@@ -227,28 +226,30 @@ class OllamaChatModelConnectionTest {
.path("type")
.asText())
.isEqualTo("integer");
-
- // Every field is required except the one the caller declared
omissible.
- assertThat(textValues(schema.path("required")))
- .containsExactlyInAnyOrder("summary", "counts", "total");
}
@Test
- @DisplayName("The generated schema names properties the way Jackson
deserializes them")
- void generatedSchemaFollowsJacksonPropertyNames() {
+ @DisplayName("The generated schema lists enum constants the way Jackson
deserializes them")
+ void generatedSchemaFollowsJacksonEnumValues() throws Exception {
OllamaChatRequest request =
connection()
- .buildRequest(userMessage(), List.of(),
params("qwen3:4b"), Profile.class);
- JsonNode schema = (JsonNode) request.getFormat();
+ .buildRequest(userMessage(), List.of(),
params("qwen3:4b"), Ticket.class);
+ JsonNode properties = ((JsonNode)
request.getFormat()).path("properties");
+
+ // Every listed value is one the model may emit, so each has to
deserialize into the enum.
+ // Listed by constant name instead, the mapper reading the response
refuses every value
+ // the schema allows.
+ List<Status> statuses = new ArrayList<>();
+ for (JsonNode value : properties.path("status").path("enum")) {
+ statuses.add(MAPPER.treeToValue(value, Status.class));
+ }
+ assertThat(statuses).containsExactlyInAnyOrder(Status.values());
- // 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.
- assertThat(schema.path("properties").fieldNames())
- .toIterable()
- .containsExactly("full_name", "age");
- assertThat(textValues(schema.path("required")))
- .containsExactlyInAnyOrder("full_name", "age");
+ List<Phase> phases = new ArrayList<>();
+ for (JsonNode value : properties.path("phase").path("enum")) {
+ phases.add(MAPPER.treeToValue(value, Phase.class));
+ }
+ assertThat(phases).containsExactlyInAnyOrder(Phase.values());
}
@ParameterizedTest
@@ -261,10 +262,4 @@ class OllamaChatModelConnectionTest {
// behavior change here.
assertThat(connection().supportsNativeStructuredOutput(model)).isTrue();
}
-
- private static List<String> textValues(JsonNode arrayNode) {
- List<String> values = new ArrayList<>();
- arrayNode.forEach(element -> values.add(element.asText()));
- return values;
- }
}
diff --git a/integrations/chat-models/pom.xml b/integrations/chat-models/pom.xml
index 37840ae7..2928abc6 100644
--- a/integrations/chat-models/pom.xml
+++ b/integrations/chat-models/pom.xml
@@ -33,6 +33,7 @@ under the License.
<modules>
<module>anthropic</module>
<module>bedrock</module>
+ <module>common</module>
<module>gemini</module>
<module>ollama</module>
<module>openai</module>
diff --git a/integrations/chat-models/watsonx/pom.xml
b/integrations/chat-models/watsonx/pom.xml
index c71baae3..6f8da02c 100644
--- a/integrations/chat-models/watsonx/pom.xml
+++ b/integrations/chat-models/watsonx/pom.xml
@@ -46,15 +46,16 @@ under the License.
<artifactId>jackson-databind</artifactId>
</dependency>
- <!-- Versions managed by the victools BOM imported in the root pom. -->
<dependency>
- <groupId>com.github.victools</groupId>
- <artifactId>jsonschema-generator</artifactId>
+ <groupId>org.apache.flink</groupId>
+
<artifactId>flink-agents-integrations-chat-models-common</artifactId>
+ <version>${project.version}</version>
</dependency>
+ <!-- Versions managed by the victools BOM imported in the root pom. -->
<dependency>
<groupId>com.github.victools</groupId>
- <artifactId>jsonschema-module-jackson</artifactId>
+ <artifactId>jsonschema-generator</artifactId>
</dependency>
<dependency>
diff --git
a/integrations/chat-models/watsonx/src/main/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnection.java
b/integrations/chat-models/watsonx/src/main/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnection.java
index 131d94cb..d8ddcc74 100644
---
a/integrations/chat-models/watsonx/src/main/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnection.java
+++
b/integrations/chat-models/watsonx/src/main/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnection.java
@@ -25,18 +25,13 @@ import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
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 org.apache.flink.agents.api.chat.messages.ChatMessage;
import org.apache.flink.agents.api.chat.messages.MessageRole;
import org.apache.flink.agents.api.chat.model.BaseChatModelConnection;
import org.apache.flink.agents.api.resource.ResourceContext;
import org.apache.flink.agents.api.resource.ResourceDescriptor;
import org.apache.flink.agents.api.tools.Tool;
+import
org.apache.flink.agents.integrations.chatmodels.common.PojoJsonSchemaGenerator;
import org.apache.flink.annotation.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -53,7 +48,6 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Matcher;
@@ -546,29 +540,17 @@ public class WatsonxChatModelConnection extends
BaseChatModelConnection {
return payload;
}
- // Derives the response_format value watsonx.ai expects from a POJO class.
Every setting below
- // addresses a concrete way the generated schema otherwise misstates the
contract:
+ // Derives the response_format value watsonx.ai expects from a POJO class.
The schema inside it
+ // comes from the shared generator with one option added:
//
- // - DRAFT_2020_12 is the draft pydantic generates on the Python side,
so a schema derived
- // from a Java class states the same contract in the same dialect.
- // - The PLAIN_JSON preset keeps generation to fields. Without a preset,
getters surface as
- // properties of their own, named after the accessor call, e.g.
"getDerived()".
// - MAP_VALUES_AS_ADDITIONAL_PROPERTIES gives a Map its value schema.
Without it the map
// admits any value, and a response that satisfies the schema can
still fail to deserialize
// into the declared value type at the caller.
- // - Sorting fields before methods and applying no further comparison
leaves properties in
- // declaration order, which is the order pydantic emits, so the
documents the two languages
- // derive from the same shape stay aligned.
- // - The required check marks every field required except an Optional
one. The default marks
- // nothing required, which lets a model omit fields at will, 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, because
- // a caller deserializing the response into this class honors
@JsonProperty and skips
- // @JsonIgnore. This connection returns the content as a string and
never deserializes into
- // the schema class, so a property stated under the wrong name
produces a response that
- // satisfies the schema and still fails to read back, at the caller
rather than here. It is
- // applied with no JacksonOption, so it contributes property naming
and visibility only:
- // the required set and the property order stay the ones configured
below.
+ //
+ // This connection returns the content as a string and never deserializes
into the schema
+ // class, so a property or enum constant the schema states under a name
Jackson does not read
+ // produces a response that satisfies the schema and still fails to read
back, at the caller
+ // rather than here.
//
// Two settings are deliberately absent:
//
@@ -584,24 +566,14 @@ public class WatsonxChatModelConnection extends
BaseChatModelConnection {
// recursive POJO reaches the provider unguarded either way, and this
setting cannot
// change that.
private static ObjectNode toNativeResponseFormat(Class<?> schemaClass) {
- SchemaGeneratorConfigBuilder configBuilder =
- new SchemaGeneratorConfigBuilder(
- SchemaVersion.DRAFT_2020_12,
OptionPreset.PLAIN_JSON)
- .with(Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES)
- .with(new JacksonModule());
- configBuilder
- .forTypesInGeneral()
-
.withPropertySorter(PropertySortUtils.SORT_PROPERTIES_FIELDS_BEFORE_METHODS);
- configBuilder
- .forFields()
- .withRequiredCheck(field ->
!Optional.class.equals(field.getRawMember().getType()));
-
final ObjectNode responseFormat = MAPPER.createObjectNode();
responseFormat.put("type", "json_schema");
final ObjectNode jsonSchema = responseFormat.putObject("json_schema");
jsonSchema.put("name", schemaClass.getSimpleName());
jsonSchema.set(
- "schema", new
SchemaGenerator(configBuilder.build()).generateSchema(schemaClass));
+ "schema",
+ PojoJsonSchemaGenerator.generate(
+ schemaClass,
Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES));
jsonSchema.put("strict", true);
return responseFormat;
}
diff --git
a/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnectionTest.java
b/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnectionTest.java
index 5dda6f7b..a1d1401e 100644
---
a/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnectionTest.java
+++
b/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnectionTest.java
@@ -17,8 +17,8 @@
*/
package org.apache.flink.agents.integrations.chatmodels.watsonx;
-import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
@@ -65,16 +65,14 @@ class WatsonxChatModelConnectionTest {
private static final String CHAT_RESPONSE =
"{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\","
+ "\"content\":\"Hello!\"},\"finish_reason\":\"stop\"}]}";
- private static final String DRAFT_2020_12 =
"https://json-schema.org/draft/2020-12/schema";
private static final String MODEL = "ibm/granite-3-3-8b-instruct";
private static final Map<String, Object> CALLER_FORMAT = Map.of("type",
"json_object");
/**
* Output schema fixture shaped to expose the schema-generation settings.
*
- * <p>Fields are declared out of alphabetical order, {@code counts} is a
map whose values carry
- * a type, {@code note} is the only optional field, and {@code getDerived}
is a getter backed by
- * no field.
+ * <p>{@code counts} is a map whose values carry a type, and {@code
getDerived} is a getter
+ * backed by no field.
*/
public static class Report {
public String summary;
@@ -88,18 +86,37 @@ class WatsonxChatModelConnectionTest {
}
/**
- * 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.
+ * Output schema fixture whose enum constants are deserialized from values
other than their
+ * names, one through {@code @JsonProperty} on the constants and one
through a
+ * {@code @JsonValue} method.
*/
- public static class Profile {
- @JsonProperty("full_name")
- public String name;
+ public static class Ticket {
+ public Status status;
+
+ public Phase phase;
+ }
+
+ public enum Status {
+ @JsonProperty("in-progress")
+ IN_PROGRESS,
+ @JsonProperty("done")
+ DONE
+ }
+
+ public enum Phase {
+ STARTED("started"),
+ FINISHED("finished");
- @JsonIgnore public String secret;
+ private final String wire;
+
+ Phase(String wire) {
+ this.wire = wire;
+ }
- public int age;
+ @JsonValue
+ public String wire() {
+ return wire;
+ }
}
private static ResourceDescriptor descriptor(String url, String apiKey,
String projectId) {
@@ -191,12 +208,6 @@ class WatsonxChatModelConnectionTest {
return names;
}
- private static List<String> textValues(JsonNode arrayNode) {
- List<String> values = new ArrayList<>();
- arrayNode.forEach(element -> values.add(element.asText()));
- return values;
- }
-
@ParameterizedTest(name = "{0}")
@MethodSource("missingRequiredConfiguration")
void testConstructorRejectsMissingRequiredConfiguration(
@@ -775,9 +786,6 @@ class WatsonxChatModelConnectionTest {
assertThat(jsonSchema.path("strict").booleanValue()).isTrue();
assertThat(fieldNames(jsonSchema.path("schema").path("properties")))
.containsExactlyInAnyOrder("summary", "counts", "note",
"total");
- // The draft is the one pydantic emits on the Python side, so both
languages state the
- // same contract in the same dialect. Nothing else in this suite pins
the draft.
-
assertThat(jsonSchema.path("schema").path("$schema").asText()).isEqualTo(DRAFT_2020_12);
}
@Test
@@ -805,28 +813,24 @@ class WatsonxChatModelConnectionTest {
}
@Test
- @DisplayName("The derived schema names properties the way Jackson
deserializes them")
- void derivedSchemaHonorsJacksonAnnotations() {
- JsonNode schema = derivedSchema(Profile.class);
-
- // A caller deserializing the response into this class accepts the
renamed property and
- // rejects the Java field name, and discards an ignored property that
the schema would
- // otherwise state as required and so force a value for.
- assertThat(fieldNames(schema.path("properties")))
- .containsExactlyInAnyOrder("full_name", "age");
- assertThat(textValues(schema.path("required")))
- .containsExactlyInAnyOrder("full_name", "age");
- }
+ @DisplayName("The derived schema lists enum constants the way Jackson
deserializes them")
+ void derivedSchemaFollowsJacksonEnumValues() throws Exception {
+ JsonNode properties = derivedSchema(Ticket.class).path("properties");
+
+ // Every listed value is one the model may emit, so each has to
deserialize into the enum.
+ // Listed by constant name instead, the caller's mapper refuses every
value the schema
+ // allows.
+ List<Status> statuses = new ArrayList<>();
+ for (JsonNode value : properties.path("status").path("enum")) {
+ statuses.add(MAPPER.treeToValue(value, Status.class));
+ }
+ assertThat(statuses).containsExactlyInAnyOrder(Status.values());
- @Test
- @DisplayName("The derived schema requires every field the caller did not
make optional")
- void derivedSchemaMarksNonOptionalFieldsRequired() {
- JsonNode schema = derivedSchema(Report.class);
-
- // Without a required set the model may omit fields the caller
declared, and with an
- // all-inclusive one it must invent a value for the field the caller
made omissible.
- assertThat(textValues(schema.path("required")))
- .containsExactlyInAnyOrder("summary", "counts", "total");
+ List<Phase> phases = new ArrayList<>();
+ for (JsonNode value : properties.path("phase").path("enum")) {
+ phases.add(MAPPER.treeToValue(value, Phase.class));
+ }
+ assertThat(phases).containsExactlyInAnyOrder(Phase.values());
}
@Test