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 25d6ea16 [integrations][java] List each enum constant by its
Jackson-serialized value in native schemas (#1260)
25d6ea16 is described below
commit 25d6ea168b31047f8d7f9d22fd2ff7916362db22
Author: Sangkyoon Nam <[email protected]>
AuthorDate: Fri Oct 16 15:38:29 2026 +0900
[integrations][java] List each enum constant by its Jackson-serialized
value in native schemas (#1260)
Generated-by: Claude Code 2.1.296 (Claude Opus 5.5)
---
.../development/calling-llms/structured-output.md | 7 +-
.../chatmodels/common/PojoJsonSchemaGenerator.java | 88 +++++++++-
.../common/PojoJsonSchemaGeneratorTest.java | 190 ++++++++++++++++++++-
3 files changed, 273 insertions(+), 12 deletions(-)
diff --git a/docs/content/docs/development/calling-llms/structured-output.md
b/docs/content/docs/development/calling-llms/structured-output.md
index 8c0c58b5..d66b30df 100644
--- a/docs/content/docs/development/calling-llms/structured-output.md
+++ b/docs/content/docs/development/calling-llms/structured-output.md
@@ -271,7 +271,7 @@ The connection's `api_version` must be `2024-08-01` or
later, for example `2024-
List the values the model should return, in the form the model reads back:
- **Python:** a Pydantic `Enum` lists its values. A string enum gives `"type":
"string"` and a numeric enum gives `"type": "integer"`.
-- **Java:** the schema lists the values Jackson deserializes. Put
`@JsonProperty` on **every** constant, or use a `@JsonValue` method that
returns a `String`.
+- **Java:** the native schema lists each constant under the value Jackson
serializes it to, typed as that value: a `@JsonProperty` name, a `@JsonValue`
result, or the Java name. An integer `@JsonValue` gives `"type": "integer"`. An
enum with `@JsonFormat(shape = OBJECT)` and no other Jackson customization is
listed by its constant names, which a default `ObjectMapper` reads back.
Jackson reads the values back by default when a `@JsonValue` method returns a
`String` or an integral primiti [...]
{{< tabs "Enum Fields" >}}
@@ -311,11 +311,6 @@ public enum Sentiment {
{{< /tabs >}}
-Two Java cases do not work as you might expect. They behave the same on every
Java provider:
-
-- `@JsonProperty` on only **some** constants lists the Java names for **all**
of them (`["POSITIVE","NEGATIVE"]`). The model may reply with a Java name where
you annotated a different value, and parsing fails. Annotate every constant or
none.
-- A `@JsonValue` that returns a number produces
`{"type":"string","enum":[1,-1]}`, which contradicts itself. Use a string value
instead.
-
### Java POJOs
- **Descriptions and required fields.** `@JsonPropertyDescription` and
`@JsonClassDescription` are sent in the native schema, and every field is
required (an `Optional` field is required but nullable on OpenAI, Azure OpenAI
and Anthropic). The ReAct Agent's *prompt text* is rendered by a different
generator, which has no descriptions, and lists a field as required only when
it has `@JsonProperty(required = true)`. The prompt path therefore describes
the schema less fully than the native path.
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
index 08a973ff..b849e05a 100644
---
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
@@ -17,23 +17,35 @@
*/
package org.apache.flink.agents.integrations.chatmodels.common;
+import com.fasterxml.classmate.ResolvedType;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+import com.github.victools.jsonschema.generator.CustomDefinition;
import com.github.victools.jsonschema.generator.FieldScope;
import com.github.victools.jsonschema.generator.Option;
import com.github.victools.jsonschema.generator.OptionPreset;
+import com.github.victools.jsonschema.generator.SchemaGenerationContext;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
+import com.github.victools.jsonschema.generator.SchemaKeyword;
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.LinkedHashSet;
import java.util.Optional;
+import java.util.Set;
import java.util.function.Predicate;
/** Derives the JSON Schema that chat model connections send as a native
output schema. */
public final class PojoJsonSchemaGenerator {
+ private static final ObjectMapper ENUM_VALUE_MAPPER = new ObjectMapper();
+
private PojoJsonSchemaGenerator() {}
// Every fixed setting below addresses a concrete way the generated schema
otherwise misstates
@@ -54,12 +66,17 @@ public final class PojoJsonSchemaGenerator {
// nullable there, so the schema still admits null for it.
// - 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
+ // otherwise mark required. A property stated under any other name
makes a response that
+ // satisfies the schema fail to deserialize.
RESPECT_JSONPROPERTY_REQUIRED stays off, so
// @JsonProperty(required = true) does not widen the required set
configured here.
+ // - Enums get their own definition, registered before the Jackson
module so it takes
+ // precedence over the module's. It lists each constant under the
value an ObjectMapper
+ // writes for it, typed as that value. The module's @JsonProperty
flattening applies only
+ // when every constant carries @JsonProperty, so in a partly annotated
enum it lists the
+ // renamed constants by Java names that fail to deserialize. Its
@JsonValue flattening
+ // types numeric values as strings, a schema no value satisfies. An
enum without
+ // constants, one whose values cannot be written, or one written as
objects or arrays
+ // (such as @JsonFormat(shape = OBJECT)) falls back to the existing
enum handling.
/**
* Generates the JSON Schema for {@code type}.
@@ -96,6 +113,8 @@ public final class PojoJsonSchemaGenerator {
for (Option option : options) {
builder.with(option);
}
+ builder.forTypesInGeneral()
+
.withCustomDefinitionProvider(PojoJsonSchemaGenerator::enumDefinition);
builder.with(
new JacksonModule(
JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY,
@@ -105,4 +124,63 @@ public final class PojoJsonSchemaGenerator {
builder.forFields().withRequiredCheck(requiredCheck);
return new SchemaGenerator(builder.build()).generateSchema(type);
}
+
+ private static CustomDefinition enumDefinition(
+ ResolvedType javaType, SchemaGenerationContext context) {
+ Class<?> enumType = javaType.getErasedType();
+ if (!enumType.isEnum()) {
+ return null;
+ }
+ Object[] constants = enumType.getEnumConstants();
+ if (constants.length == 0) {
+ return null;
+ }
+ ArrayNode values = context.getGeneratorConfig().createArrayNode();
+ Set<String> types = new LinkedHashSet<>();
+ for (Object constant : constants) {
+ JsonNode value;
+ try {
+ value = ENUM_VALUE_MAPPER.valueToTree(constant);
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ if (value.isContainerNode()) {
+ // Handle only scalar values here. Object and array values
fall back to the
+ // existing enum handling; reading them back may require a
@JsonCreator.
+ return null;
+ }
+ if ((value.isFloat() || value.isDouble()) &&
!Double.isFinite(value.doubleValue())) {
+ // JSON has no non-finite numbers; Jackson writes these as
strings such as "NaN".
+ value = TextNode.valueOf(value.asText());
+ }
+ values.add(value);
+ types.add(jsonType(value, context));
+ }
+ ObjectNode definition =
context.getGeneratorConfig().createObjectNode();
+ if (types.size() == 1) {
+ definition.put(context.getKeyword(SchemaKeyword.TAG_TYPE),
types.iterator().next());
+ } else {
+ ArrayNode typeArray =
definition.putArray(context.getKeyword(SchemaKeyword.TAG_TYPE));
+ types.forEach(typeArray::add);
+ }
+ if (values.size() == 1) {
+ definition.set(context.getKeyword(SchemaKeyword.TAG_CONST),
values.get(0));
+ } else {
+ definition.set(context.getKeyword(SchemaKeyword.TAG_ENUM), values);
+ }
+ return new CustomDefinition(definition);
+ }
+
+ private static String jsonType(JsonNode value, SchemaGenerationContext
context) {
+ if (value.isIntegralNumber()) {
+ return context.getKeyword(SchemaKeyword.TAG_TYPE_INTEGER);
+ } else if (value.isNumber()) {
+ return context.getKeyword(SchemaKeyword.TAG_TYPE_NUMBER);
+ } else if (value.isBoolean()) {
+ return context.getKeyword(SchemaKeyword.TAG_TYPE_BOOLEAN);
+ } else if (value.isNull()) {
+ return context.getKeyword(SchemaKeyword.TAG_TYPE_NULL);
+ }
+ return context.getKeyword(SchemaKeyword.TAG_TYPE_STRING);
+ }
}
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
index 1d567f6e..9bcace4a 100644
---
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
@@ -17,6 +17,7 @@
*/
package org.apache.flink.agents.integrations.chatmodels.common;
+import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;
@@ -27,6 +28,7 @@ import com.github.victools.jsonschema.generator.Option;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -103,6 +105,38 @@ class PojoJsonSchemaGeneratorTest {
}
}
+ /**
+ * Output schema fixture for enums the Jackson module's flattened enums
misstate: one maps only
+ * some of its constants through {@code @JsonProperty}, and one maps its
constants to numbers
+ * through a {@code @JsonValue} method.
+ */
+ public static class Order {
+ public PartlyMapped status;
+ public Sign sign;
+ }
+
+ public enum PartlyMapped {
+ @JsonProperty("in-progress")
+ IN_PROGRESS,
+ DONE
+ }
+
+ public enum Sign {
+ POSITIVE(1),
+ NEGATIVE(-1);
+
+ private final int value;
+
+ Sign(int value) {
+ this.value = value;
+ }
+
+ @JsonValue
+ public int value() {
+ return value;
+ }
+ }
+
@Test
@DisplayName("The generated schema names properties the way Jackson
deserializes them")
void namesPropertiesTheWayJacksonReadsThem() {
@@ -135,6 +169,160 @@ class PojoJsonSchemaGeneratorTest {
assertThat(phases).containsExactlyInAnyOrder(Phase.values());
}
+ @Test
+ @DisplayName("The generated schema lists a partly annotated enum the way
Jackson reads it")
+ void listsPartlyAnnotatedEnumByItsJacksonWireValues() throws Exception {
+ JsonNode status =
+
PojoJsonSchemaGenerator.generate(Order.class).path("properties").path("status");
+
+ // Listing the Java name of the annotated constant would let the model
emit a value that
+ // fails to deserialize, so the schema has to name it by its
@JsonProperty value.
+
assertThat(textValues(status.path("enum"))).containsExactly("in-progress",
"DONE");
+ for (JsonNode value : status.path("enum")) {
+ MAPPER.treeToValue(value, PartlyMapped.class);
+ }
+ }
+
+ @Test
+ @DisplayName("The enum definition takes precedence over the Jackson
module's enum handling")
+ void enumDefinitionWinsOverJacksonModule() throws Exception {
+ JsonNode sign =
+
PojoJsonSchemaGenerator.generate(Order.class).path("properties").path("sign");
+
+ // The Jackson module also handles a @JsonValue enum, typing it as a
string even when the
+ // values are numbers, so a numeric type here comes from the
generator's own definition.
+
assertThat(sign.toString()).isEqualTo("{\"type\":\"integer\",\"enum\":[1,-1]}");
+ List<Sign> signs = new ArrayList<>();
+ for (JsonNode value : sign.path("enum")) {
+ signs.add(MAPPER.treeToValue(value, Sign.class));
+ }
+ assertThat(signs).containsExactly(Sign.POSITIVE, Sign.NEGATIVE);
+ }
+
+ /** Output schema fixture for enums whose written values need care in a
schema. */
+ public static class Measures {
+ public Limit limit;
+ public Only only;
+ public Huge huge;
+ }
+
+ public enum Limit {
+ FINITE(1.5),
+ UNBOUNDED(Double.POSITIVE_INFINITY),
+ UNDEFINED(Double.NaN);
+
+ private final double value;
+
+ Limit(double value) {
+ this.value = value;
+ }
+
+ @JsonValue
+ public double value() {
+ return value;
+ }
+ }
+
+ public enum Huge {
+ BEYOND_DOUBLE(new BigDecimal("1E400")),
+ ONE(BigDecimal.ONE);
+
+ private final BigDecimal value;
+
+ Huge(BigDecimal value) {
+ this.value = value;
+ }
+
+ @JsonValue
+ public BigDecimal value() {
+ return value;
+ }
+ }
+
+ public enum Only {
+ @JsonProperty("only")
+ ONLY
+ }
+
+ @Test
+ @DisplayName("The enum definition writes non-finite numbers as strings and
one value as const")
+ void writesNonFiniteNumbersAsStringsAndSingleValuesAsConst() {
+ for (ObjectNode schema :
+ List.of(
+ PojoJsonSchemaGenerator.generate(Measures.class),
+
PojoJsonSchemaGenerator.generateAllRequired(Measures.class))) {
+ JsonNode properties = schema.path("properties");
+
+ // JSON has no NaN or Infinity, so Jackson writes them as strings,
and the schema has
+ // to list them the same way; a single value keeps the const form
victools uses.
+ assertThat(properties.path("limit").toString())
+ .isEqualTo(
+
"{\"type\":[\"number\",\"string\"],\"enum\":[1.5,\"Infinity\",\"NaN\"]}");
+ assertThat(properties.path("only").toString())
+ .isEqualTo("{\"type\":\"string\",\"const\":\"only\"}");
+ // A finite decimal beyond double's range is still written as a
number.
+
assertThat(properties.path("huge").path("type").asText()).isEqualTo("number");
+
assertThat(properties.path("huge").path("enum").get(0).isNumber()).isTrue();
+ }
+ }
+
+ /** Output schema fixture holding an enum that declares no constants. */
+ public static class Placeholder {
+ public Optional<Empty> empty;
+ }
+
+ public enum Empty {}
+
+ @Test
+ @DisplayName("An enum without constants keeps the schema it had before")
+ void leavesEnumWithoutConstantsToJacksonModule() {
+ // An enum definition built from no values would declare an empty type
list, which no
+ // instance satisfies, so even the null the Optional field allows
would be rejected.
+ for (ObjectNode schema :
+ List.of(
+ PojoJsonSchemaGenerator.generate(Placeholder.class),
+
PojoJsonSchemaGenerator.generateAllRequired(Placeholder.class))) {
+ JsonNode empty = schema.path("properties").path("empty");
+ assertThat(empty.toString()).doesNotContain("\"type\":[]");
+ assertThat(textValues(empty.path("type"))).contains("null");
+ }
+ }
+
+ /** Output schema fixture holding an enum that Jackson writes as an
object. */
+ public static class Shipment {
+ public Carrier carrier;
+ }
+
+ @JsonFormat(shape = JsonFormat.Shape.OBJECT)
+ public enum Carrier {
+ GROUND(1),
+ AIR(2);
+
+ public final int code;
+
+ Carrier(int code) {
+ this.code = code;
+ }
+ }
+
+ @Test
+ @DisplayName("An enum written as an object keeps its constant names in the
schema")
+ void listsObjectShapedEnumByItsConstantNames() throws Exception {
+ // Listed as objects, the values would sit under "type":"object" with
no declared
+ // properties, which a strict schema rejects, and a plain mapper
cannot read them back.
+ JsonNode carrier =
+ PojoJsonSchemaGenerator.generateAllRequired(
+ Shipment.class,
Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT)
+ .path("properties")
+ .path("carrier");
+ assertThat(carrier.path("type").asText()).isEqualTo("string");
+ List<Carrier> carriers = new ArrayList<>();
+ for (JsonNode value : carrier.path("enum")) {
+ carriers.add(MAPPER.treeToValue(value, Carrier.class));
+ }
+ assertThat(carriers).containsExactly(Carrier.GROUND, Carrier.AIR);
+ }
+
@Test
@DisplayName("The generated schema requires every field the caller did not
make optional")
void requiresEveryFieldExceptOptional() {
@@ -162,7 +350,7 @@ class PojoJsonSchemaGeneratorTest {
@Test
@DisplayName("The all-required schema differs from the default one only in
its required set")
void allRequiredChangesOnlyTheRequiredSet() {
- for (Class<?> type : List.of(Report.class, Profile.class,
Ticket.class)) {
+ for (Class<?> type : List.of(Report.class, Profile.class,
Ticket.class, Order.class)) {
ObjectNode allRequired =
PojoJsonSchemaGenerator.generateAllRequired(
type,
Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT);