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 b70f8a63 [integrations][java] List Jackson enum values in OpenAI, 
Azure and Anthropic native schemas (#1247)
b70f8a63 is described below

commit b70f8a6336019719c970974d4f30dd8074283c7d
Author: Weiqing Yang <[email protected]>
AuthorDate: Tue Oct 13 19:56:57 2026 -0700

    [integrations][java] List Jackson enum values in OpenAI, Azure and 
Anthropic native schemas (#1247)
    
    Generated-by: GitHub Copilot CLI 1.0.95 (claude-opus-5.5)
---
 integrations/chat-models/anthropic/pom.xml         |  12 ++
 .../anthropic/AnthropicChatModelConnection.java    |  56 ++++-----
 .../AnthropicChatModelConnectionTest.java          | 114 +++++++++++++++++-
 .../chatmodels/common/PojoJsonSchemaGenerator.java |  35 +++++-
 .../common/PojoJsonSchemaGeneratorTest.java        |  32 ++++++
 integrations/chat-models/openai/pom.xml            |  12 ++
 .../openai/AzureOpenAIChatModelConnection.java     |  24 +---
 .../openai/OpenAIChatCompletionsUtils.java         |  38 +++++-
 .../openai/OpenAICompletionsConnection.java        |  25 +---
 .../openai/AzureOpenAIChatModelConnectionTest.java |  68 ++++++++++-
 .../openai/OpenAICompletionsConnectionTest.java    | 127 ++++++++++++++++++++-
 11 files changed, 448 insertions(+), 95 deletions(-)

diff --git a/integrations/chat-models/anthropic/pom.xml 
b/integrations/chat-models/anthropic/pom.xml
index 07d0b592..aad13568 100644
--- a/integrations/chat-models/anthropic/pom.xml
+++ b/integrations/chat-models/anthropic/pom.xml
@@ -44,6 +44,18 @@ under the License.
             <version>${anthropic.version}</version>
         </dependency>
 
+        <dependency>
+            <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-generator</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>org.slf4j</groupId>
             <artifactId>slf4j-api</artifactId>
diff --git 
a/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
 
b/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
index d4c3ecee..7331e82a 100644
--- 
a/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
+++ 
b/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
@@ -19,7 +19,8 @@ package 
org.apache.flink.agents.integrations.chatmodels.anthropic;
 
 import com.anthropic.client.AnthropicClient;
 import com.anthropic.client.okhttp.AnthropicOkHttpClient;
-import com.anthropic.core.JsonSchemaLocalValidation;
+import com.anthropic.core.JsonValue;
+import com.anthropic.models.messages.JsonOutputFormat;
 import com.anthropic.models.messages.Message;
 import com.anthropic.models.messages.MessageCreateParams;
 import com.anthropic.models.messages.MessageParam;
@@ -27,6 +28,7 @@ import com.anthropic.models.messages.Model;
 import com.anthropic.models.messages.OutputConfig;
 import com.anthropic.models.messages.TextBlockParam;
 import com.anthropic.models.messages.Tool;
+import com.github.victools.jsonschema.generator.Option;
 import org.apache.flink.agents.api.chat.messages.ChatMessage;
 import org.apache.flink.agents.api.chat.messages.ChatResult;
 import org.apache.flink.agents.api.chat.messages.MessageRole;
@@ -35,11 +37,13 @@ import 
org.apache.flink.agents.api.chat.model.BaseChatModelConnection;
 import org.apache.flink.agents.api.chat.model.NativeStructuredOutputSupport;
 import org.apache.flink.agents.api.resource.ResourceContext;
 import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import 
org.apache.flink.agents.integrations.chatmodels.common.PojoJsonSchemaGenerator;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.time.Duration;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -402,34 +406,32 @@ public class AnthropicChatModelConnection extends 
BaseChatModelConnection {
     }
 
     /**
-     * Derives the native {@code output_config} for a POJO class through the 
SDK's typed
-     * structured-output builder.
+     * Derives the native {@code output_config} for a POJO output schema class.
      *
-     * <p>The Kotlin facade {@code StructuredOutputsKt.outputFormatFromClass} 
would produce this
-     * directly, but it is compiled {@code ACC_SYNTHETIC} and so cannot be 
named from Java. The
-     * typed builder generates the same schema; the config is extracted from 
the throwaway request
-     * it produces and reattached to the real one, which also avoids that 
overload's side effect of
-     * retyping the request and the response as {@code 
StructuredMessageCreateParams} and {@code
-     * StructuredMessage}. The throwaway request is never sent, so its 
placeholder model, message
-     * and token limit only have to satisfy the builder's required-field check.
-     *
-     * <p>Local schema validation is off so that the provider, not the client, 
is the authority on
-     * which schemas it accepts.
+     * <p>The schema comes from the shared generator rather than the SDK's 
typed {@code
+     * outputConfig(Class)} builder, whose schema lists an annotated enum 
constant by its Java name,
+     * a value the response then fails to deserialize from. It lists every 
property as required and
+     * forbids additional properties, as the schema that builder derives does.
      */
-    private static <T> OutputConfig toNativeOutputConfig(Class<T> schemaClass) 
{
-        return MessageCreateParams.builder()
-                .model(Model.of(""))
-                .addUserMessage("")
-                .maxTokens(1)
-                .outputConfig(schemaClass, JsonSchemaLocalValidation.NO)
-                .build()
-                .rawParams()
-                .outputConfig()
-                .orElseThrow(
-                        () ->
-                                new IllegalStateException(
-                                        "Anthropic SDK did not produce an 
output_config for schema "
-                                                + schemaClass.getName()));
+    private static OutputConfig toNativeOutputConfig(Class<?> schemaClass) {
+        Map<String, JsonValue> schema = new LinkedHashMap<>();
+        PojoJsonSchemaGenerator.generateAllRequired(
+                        schemaClass, 
Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT)
+                .fields()
+                .forEachRemaining(
+                        member ->
+                                schema.put(
+                                        member.getKey(),
+                                        
JsonValue.fromJsonNode(member.getValue())));
+        return OutputConfig.builder()
+                .format(
+                        JsonOutputFormat.builder()
+                                .schema(
+                                        JsonOutputFormat.Schema.builder()
+                                                .additionalProperties(schema)
+                                                .build())
+                                .build())
+                .build();
     }
 
     @Override
diff --git 
a/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
 
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
index 77058ac9..54d9077d 100644
--- 
a/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
+++ 
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
@@ -18,6 +18,7 @@
 
 package org.apache.flink.agents.integrations.chatmodels.anthropic;
 
+import com.anthropic.core.ObjectMappers;
 import com.anthropic.models.messages.ContentBlockParam;
 import com.anthropic.models.messages.Message;
 import com.anthropic.models.messages.MessageCreateParams;
@@ -27,9 +28,12 @@ import com.anthropic.models.messages.OutputConfig;
 import com.anthropic.models.messages.StopReason;
 import com.anthropic.models.messages.TextBlock;
 import com.anthropic.models.messages.Usage;
+import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.annotation.JsonSubTypes;
 import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonValue;
 import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.flink.agents.api.chat.messages.ChatMessage;
 import org.apache.flink.agents.api.chat.messages.ChatResult;
@@ -52,6 +56,7 @@ import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 import org.junit.jupiter.params.provider.NullSource;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
@@ -310,12 +315,12 @@ class AnthropicChatModelConnectionTest {
                 "");
     }
 
-    /** A POJO the SDK can derive a JSON schema from. */
+    /** A representative POJO output schema. */
     public static class Answer {
         public String verdict;
     }
 
-    /** A polymorphic member, which the SDK renders as a discriminated union. 
*/
+    /** A polymorphic member, which the derived schema states as a 
discriminated union. */
     @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
     @JsonSubTypes({
         @JsonSubTypes.Type(value = Dog.class, name = "dog"),
@@ -339,6 +344,48 @@ class AnthropicChatModelConnectionTest {
         public Pet pet;
     }
 
+    /**
+     * 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;
+    }
+
+    /** Enum constants deserialized from their {@code @JsonProperty} values. */
+    public enum Status {
+        @JsonProperty("in-progress")
+        IN_PROGRESS,
+        @JsonProperty("done")
+        DONE
+    }
+
+    /** Enum constants deserialized from their {@code @JsonValue} method. */
+    public enum Phase {
+        STARTED("started"),
+        FINISHED("finished");
+
+        private final String wire;
+
+        Phase(String wire) {
+            this.wire = wire;
+        }
+
+        @JsonValue
+        public String wire() {
+            return wire;
+        }
+    }
+
+    /** Output schema fixture with an {@code Optional} field and a nested 
object. */
+    public static class Draft {
+        public String title;
+        public Optional<String> note;
+        public Answer answer;
+    }
+
     private static Map<String, Object> paramsWithModel(String model, Object 
jsonPrefill) {
         Map<String, Object> params = params(jsonPrefill);
         params.put("model", model);
@@ -367,6 +414,23 @@ class AnthropicChatModelConnectionTest {
                 .orElse(Set.of());
     }
 
+    /** The JSON Schema the request carries, as a JSON tree. */
+    private static JsonNode 
nativeSchema(AnthropicChatModelConnection.BuiltRequest built) {
+        return ObjectMappers.jsonMapper()
+                .valueToTree(
+                        built.params
+                                .outputConfig()
+                                .flatMap(OutputConfig::format)
+                                .orElseThrow()
+                                .schema());
+    }
+
+    private static List<String> textValues(JsonNode array) {
+        List<String> values = new ArrayList<>();
+        array.forEach(value -> values.add(value.asText()));
+        return values;
+    }
+
     /** The JSON Schema the request carries, as the SDK holds it on the output 
config. */
     private static String 
nativeSchemaPayload(AnthropicChatModelConnection.BuiltRequest built) {
         return built.params
@@ -495,10 +559,11 @@ class AnthropicChatModelConnectionTest {
     }
 
     @Test
-    @DisplayName("a polymorphic member is sent as the discriminated union the 
SDK derives")
+    @DisplayName("a polymorphic member is sent as a discriminated union")
     void testPolymorphicMemberSchemaIsSent() {
-        // Jackson renders this member as an object declaring no properties, 
while the SDK derives
-        // the full union the provider accepts. Reading a Jackson-rendered 
schema here would refuse
+        // Jackson renders this member as an object declaring no properties, 
while the schema
+        // generator derives the full union the provider accepts. Reading a 
Jackson-rendered schema
+        // here would refuse
         // a request that works.
         assertThat(nativeSchemaPayload(build(CAPABLE_MODEL, Owner.class, 
null)))
                 .contains("bark={type=string}")
@@ -506,6 +571,45 @@ class AnthropicChatModelConnectionTest {
                 .contains("kind={const=dog}");
     }
 
+    @Test
+    @DisplayName("the derived schema lists enum constants the way Jackson 
deserializes them")
+    void derivedSchemaFollowsJacksonEnumValues() throws Exception {
+        JsonNode properties =
+                nativeSchema(build(CAPABLE_MODEL, Ticket.class, 
null)).path("properties");
+
+        // Structured outputs confine the model to the listed values, 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 derived schema requires every field and forbids extras 
at every level")
+    void derivedSchemaRequiresEveryFieldAndForbidsExtras() {
+        JsonNode schema = nativeSchema(build(CAPABLE_MODEL, Draft.class, 
null));
+
+        // A response can neither drop a declared field nor add an undeclared 
one; the Optional
+        // field is required too and admits null instead.
+        assertThat(textValues(schema.path("required")))
+                .containsExactlyInAnyOrder("title", "note", "answer");
+        
assertThat(textValues(schema.path("properties").path("note").path("type")))
+                .contains("null");
+        
assertThat(schema.path("additionalProperties").asBoolean(true)).isFalse();
+        JsonNode answer = schema.path("properties").path("answer");
+        
assertThat(textValues(answer.path("required"))).containsExactly("verdict");
+        
assertThat(answer.path("additionalProperties").asBoolean(true)).isFalse();
+    }
+
     @Test
     @DisplayName("a caller-supplied output_config wins and the schema falls 
back")
     void testCallerOutputConfigWinsOverSchema() {
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 e36322e9..08a973ff 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
@@ -18,6 +18,7 @@
 package org.apache.flink.agents.integrations.chatmodels.common;
 
 import com.fasterxml.jackson.databind.node.ObjectNode;
+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.SchemaGenerator;
@@ -28,6 +29,7 @@ import 
com.github.victools.jsonschema.module.jackson.JacksonModule;
 import com.github.victools.jsonschema.module.jackson.JacksonOption;
 
 import java.util.Optional;
+import java.util.function.Predicate;
 
 /** Derives the JSON Schema that chat model connections send as a native 
output schema. */
 public final class PojoJsonSchemaGenerator {
@@ -44,9 +46,12 @@ public final class PojoJsonSchemaGenerator {
     //     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 required check of generate 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.
+    //     generateAllRequired marks every field required for callers whose 
provider expects every
+    //     property in the required set, as OpenAI's strict mode does. An 
Optional field stays
+    //     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
@@ -64,6 +69,27 @@ public final class PojoJsonSchemaGenerator {
      * @return the schema document
      */
     public static ObjectNode generate(Class<?> type, Option... options) {
+        return generate(
+                type, field -> 
!Optional.class.equals(field.getRawMember().getType()), options);
+    }
+
+    /**
+     * Generates the JSON Schema for {@code type} with every field required.
+     *
+     * <p>The schema differs from the one {@link #generate} derives only in 
its required set, which
+     * also lists an {@code Optional} field. That field's schema admits null, 
so a response can
+     * still send null for it.
+     *
+     * @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 generateAllRequired(Class<?> type, Option... 
options) {
+        return generate(type, field -> true, options);
+    }
+
+    private static ObjectNode generate(
+            Class<?> type, Predicate<FieldScope> requiredCheck, Option... 
options) {
         SchemaGeneratorConfigBuilder builder =
                 new SchemaGeneratorConfigBuilder(
                         SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON);
@@ -76,8 +102,7 @@ public final class PojoJsonSchemaGenerator {
                         JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE));
         builder.forTypesInGeneral()
                 
.withPropertySorter(PropertySortUtils.SORT_PROPERTIES_FIELDS_BEFORE_METHODS);
-        builder.forFields()
-                .withRequiredCheck(field -> 
!Optional.class.equals(field.getRawMember().getType()));
+        builder.forFields().withRequiredCheck(requiredCheck);
         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
index 838336a7..1d567f6e 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
@@ -22,6 +22,7 @@ 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.ObjectNode;
 import com.github.victools.jsonschema.generator.Option;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
@@ -145,6 +146,37 @@ class PojoJsonSchemaGeneratorTest {
                 .containsExactlyInAnyOrder("summary", "counts", "total");
     }
 
+    @Test
+    @DisplayName("The all-required schema requires an Optional field and 
admits null for it")
+    void allRequiredRequiresOptionalFieldsAsNullable() {
+        JsonNode schema = 
PojoJsonSchemaGenerator.generateAllRequired(Report.class);
+
+        // A provider expecting every property in the required set rejects a 
schema that leaves
+        // the Optional field out, so the field stays omissible only by taking 
null.
+        assertThat(textValues(schema.path("required")))
+                .containsExactlyInAnyOrder("summary", "counts", "note", 
"total");
+        
assertThat(textValues(schema.path("properties").path("note").path("type")))
+                .contains("null");
+    }
+
+    @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)) {
+            ObjectNode allRequired =
+                    PojoJsonSchemaGenerator.generateAllRequired(
+                            type, 
Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT);
+            ObjectNode partlyRequired =
+                    PojoJsonSchemaGenerator.generate(
+                            type, 
Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT);
+            allRequired.remove("required");
+            partlyRequired.remove("required");
+
+            // Compared as text, so property order, enum values and the option 
count too.
+            
assertThat(allRequired.toString()).isEqualTo(partlyRequired.toString());
+        }
+    }
+
     @Test
     @DisplayName("The generated schema keeps declaration order and emits no 
getter property")
     void keepsDeclarationOrderWithoutGetters() {
diff --git a/integrations/chat-models/openai/pom.xml 
b/integrations/chat-models/openai/pom.xml
index 361ed301..3f4badcd 100644
--- a/integrations/chat-models/openai/pom.xml
+++ b/integrations/chat-models/openai/pom.xml
@@ -44,6 +44,18 @@ under the License.
             <version>${openai.version}</version>
         </dependency>
 
+        <dependency>
+            <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-generator</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>org.slf4j</groupId>
             <artifactId>slf4j-api</artifactId>
diff --git 
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnection.java
 
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnection.java
index 2db309aa..9c69cc5e 100644
--- 
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnection.java
+++ 
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnection.java
@@ -25,12 +25,10 @@ import com.openai.azure.AzureUrlPathMode;
 import com.openai.azure.credential.AzureApiKeyCredential;
 import com.openai.client.OpenAIClient;
 import com.openai.client.okhttp.OpenAIOkHttpClient;
-import com.openai.core.JsonSchemaLocalValidation;
 import com.openai.core.JsonValue;
 import com.openai.models.ChatModel;
 import com.openai.models.FunctionDefinition;
 import com.openai.models.FunctionParameters;
-import com.openai.models.ResponseFormatJsonSchema;
 import com.openai.models.chat.completions.ChatCompletion;
 import com.openai.models.chat.completions.ChatCompletionCreateParams;
 import com.openai.models.chat.completions.ChatCompletionFunctionTool;
@@ -420,7 +418,7 @@ public class AzureOpenAIChatModelConnection extends 
BaseChatModelConnection {
         String nativeSchemaName = null;
         if (canApplyNativeStructuredOutput(outputSchema, tools, 
rawModelParams)) {
             Class<?> schemaClass = (Class<?>) outputSchema;
-            builder.responseFormat(toNativeResponseFormat(schemaClass));
+            
builder.responseFormat(OpenAIChatCompletionsUtils.toNativeResponseFormat(schemaClass));
             nativeSchemaName = schemaClass.getSimpleName();
         }
 
@@ -471,26 +469,6 @@ public class AzureOpenAIChatModelConnection extends 
BaseChatModelConnection {
         return builder.build();
     }
 
-    // Derives the strict json_schema response format from a POJO class via 
the SDK's typed
-    // structured-output builder. The Kotlin-facade 
StructuredOutputsKt.responseFormatFromClass is
-    // not callable from Java, so the response format is extracted through the 
typed builder, which
-    // generates the same strict draft-2020-12 schema, and then reattached to 
the standard builder.
-    private static <T> ResponseFormatJsonSchema 
toNativeResponseFormat(Class<T> schemaClass) {
-        return ChatCompletionCreateParams.builder()
-                .model(ChatModel.of(""))
-                .addUserMessage("")
-                .responseFormat(schemaClass, JsonSchemaLocalValidation.NO)
-                .build()
-                .rawParams()
-                .responseFormat()
-                .orElseThrow(
-                        () ->
-                                new IllegalStateException(
-                                        "OpenAI SDK did not produce a 
response_format for schema "
-                                                + schemaClass.getName()))
-                .asJsonSchema();
-    }
-
     @Override
     public void close() throws Exception {
         this.client.close();
diff --git 
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtils.java
 
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtils.java
index 04712f30..c2a96e36 100644
--- 
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtils.java
+++ 
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtils.java
@@ -20,8 +20,10 @@ package 
org.apache.flink.agents.integrations.chatmodels.openai;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.victools.jsonschema.generator.Option;
 import com.openai.core.JsonValue;
 import com.openai.core.Timeout;
+import com.openai.models.ResponseFormatJsonSchema;
 import com.openai.models.chat.completions.ChatCompletionAssistantMessageParam;
 import com.openai.models.chat.completions.ChatCompletionContentPart;
 import com.openai.models.chat.completions.ChatCompletionContentPartImage;
@@ -48,6 +50,7 @@ import 
org.apache.flink.agents.api.chat.messages.ToolResultBlock;
 import 
org.apache.flink.agents.api.chat.messages.UnsupportedContentBlockException;
 import org.apache.flink.agents.api.chat.messages.UrlSource;
 import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import 
org.apache.flink.agents.integrations.chatmodels.common.PojoJsonSchemaGenerator;
 
 import java.math.BigDecimal;
 import java.math.BigInteger;
@@ -64,8 +67,8 @@ import java.util.stream.Collectors;
 /**
  * Static helpers for converting between Flink Agents {@link ChatMessage} and 
OpenAI Chat
  * Completions API message types, plus shared parsing/validation of common 
connection arguments
- * ({@code timeout}, {@code max_retries}). No tool-definition conversion — 
that stays
- * per-connection.
+ * ({@code timeout}, {@code max_retries}) and the native response format for 
an output schema. No
+ * tool-definition conversion — that stays per-connection.
  *
  * <p>Used by both {@code OpenAICompletionsConnection} (OpenAI / 
OpenAI-compatible providers) and
  * {@code AzureOpenAIChatModelConnection} (Azure OpenAI). Both rely on the 
same openai-java SDK
@@ -175,6 +178,37 @@ final class OpenAIChatCompletionsUtils {
         }
     }
 
+    /**
+     * Derives the strict {@code json_schema} response format for a POJO 
output schema class.
+     *
+     * <p>The schema comes from the shared generator rather than the SDK's 
typed {@code
+     * responseFormat(Class)} builder, whose schema lists an annotated enum 
constant by its Java
+     * name, a value the response then fails to deserialize from. Strict mode 
expects every property
+     * in the required set and no additional properties, so the schema states 
both.
+     */
+    static ResponseFormatJsonSchema toNativeResponseFormat(Class<?> 
schemaClass) {
+        Map<String, JsonValue> schema = new LinkedHashMap<>();
+        PojoJsonSchemaGenerator.generateAllRequired(
+                        schemaClass, 
Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT)
+                .fields()
+                .forEachRemaining(
+                        member ->
+                                schema.put(
+                                        member.getKey(),
+                                        
JsonValue.fromJsonNode(member.getValue())));
+        return ResponseFormatJsonSchema.builder()
+                .jsonSchema(
+                        ResponseFormatJsonSchema.JsonSchema.builder()
+                                .name("json-schema-from-" + 
schemaClass.getSimpleName())
+                                .schema(
+                                        
ResponseFormatJsonSchema.JsonSchema.Schema.builder()
+                                                .additionalProperties(schema)
+                                                .build())
+                                .strict(true)
+                                .build())
+                .build();
+    }
+
     /** Convert a list of Flink Agents ChatMessages to OpenAI 
ChatCompletionMessageParams. */
     public static List<ChatCompletionMessageParam> convertToOpenAIMessages(
             List<ChatMessage> messages) {
diff --git 
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnection.java
 
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnection.java
index 2724829b..bc3cf9fd 100644
--- 
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnection.java
+++ 
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnection.java
@@ -22,13 +22,11 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.openai.client.OpenAIClient;
 import com.openai.client.okhttp.OpenAIOkHttpClient;
-import com.openai.core.JsonSchemaLocalValidation;
 import com.openai.core.JsonValue;
 import com.openai.models.ChatModel;
 import com.openai.models.FunctionDefinition;
 import com.openai.models.FunctionParameters;
 import com.openai.models.ReasoningEffort;
-import com.openai.models.ResponseFormatJsonSchema;
 import com.openai.models.chat.completions.ChatCompletion;
 import com.openai.models.chat.completions.ChatCompletionCreateParams;
 import com.openai.models.chat.completions.ChatCompletionFunctionTool;
@@ -307,7 +305,8 @@ public class OpenAICompletionsConnection extends 
BaseChatModelConnection {
         // the branch applies whenever the query answers other than 
INFEASIBLE. Model capability is
         // not asked: a caller that hands this connection a schema has already 
chosen to send it.
         if (canApplyNativeStructuredOutput(outputSchema, tools, 
rawModelParams)) {
-            builder.responseFormat(toNativeResponseFormat((Class<?>) 
outputSchema));
+            builder.responseFormat(
+                    
OpenAIChatCompletionsUtils.toNativeResponseFormat((Class<?>) outputSchema));
         }
 
         Object temperature = modelParams.remove("temperature");
@@ -348,26 +347,6 @@ public class OpenAICompletionsConnection extends 
BaseChatModelConnection {
         return builder.build();
     }
 
-    // Derives the strict json_schema response format from a POJO class via 
the SDK's typed
-    // structured-output builder. The Kotlin-facade 
StructuredOutputsKt.responseFormatFromClass is
-    // not callable from Java, so the response format is extracted through the 
typed builder, which
-    // generates the same strict draft-2020-12 schema, and then reattached to 
the standard builder.
-    private static <T> ResponseFormatJsonSchema 
toNativeResponseFormat(Class<T> schemaClass) {
-        return ChatCompletionCreateParams.builder()
-                .model(ChatModel.of(""))
-                .addUserMessage("")
-                .responseFormat(schemaClass, JsonSchemaLocalValidation.NO)
-                .build()
-                .rawParams()
-                .responseFormat()
-                .orElseThrow(
-                        () ->
-                                new IllegalStateException(
-                                        "OpenAI SDK did not produce a 
response_format for schema "
-                                                + schemaClass.getName()))
-                .asJsonSchema();
-    }
-
     private List<ChatCompletionTool> convertTools(List<Tool> tools, boolean 
strictMode) {
         List<ChatCompletionTool> openaiTools = new ArrayList<>(tools.size());
         for (Tool tool : tools) {
diff --git 
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnectionTest.java
 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnectionTest.java
index d4db5351..a4bd0bfd 100644
--- 
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnectionTest.java
+++ 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnectionTest.java
@@ -18,12 +18,16 @@
 
 package org.apache.flink.agents.integrations.chatmodels.openai;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.annotation.JsonSubTypes;
 import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.openai.core.JsonField;
 import com.openai.core.JsonMissing;
 import com.openai.core.JsonValue;
+import com.openai.core.ObjectMappers;
 import com.openai.errors.BadRequestException;
 import com.openai.models.ChatModel;
 import com.openai.models.ResponseFormatJsonSchema;
@@ -54,6 +58,7 @@ import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.IOException;
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
@@ -84,6 +89,8 @@ class AzureOpenAIChatModelConnectionTest {
 
     private static final Map<String, Object> CALLER_RESPONSE_FORMAT = 
Map.of("type", "json_object");
 
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
     private static ResourceDescriptor.Builder connectionDescriptor() {
         return ResourceDescriptor.Builder.newBuilder(
                 AzureOpenAIChatModelConnection.class.getName());
@@ -95,7 +102,7 @@ class AzureOpenAIChatModelConnectionTest {
         public int age;
     }
 
-    /** A polymorphic member, which the SDK renders as a discriminated union. 
*/
+    /** A polymorphic member, which the derived schema states as a 
discriminated union. */
     @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
     @JsonSubTypes({
         @JsonSubTypes.Type(value = Dog.class, name = "dog"),
@@ -119,6 +126,30 @@ class AzureOpenAIChatModelConnectionTest {
         public Pet pet;
     }
 
+    /** Output schema fixture whose enum constants are deserialized from 
values other than names. */
+    public static class Ticket {
+        public Status status;
+    }
+
+    /** Enum constants deserialized from their {@code @JsonProperty} values. */
+    public enum Status {
+        @JsonProperty("in-progress")
+        IN_PROGRESS,
+        @JsonProperty("done")
+        DONE
+    }
+
+    /** The JSON Schema the request carries, as a JSON tree. */
+    private static JsonNode nativeSchema(ChatCompletionCreateParams params) {
+        return ObjectMappers.jsonMapper()
+                .valueToTree(
+                        params.responseFormat()
+                                .orElseThrow()
+                                .asJsonSchema()
+                                .jsonSchema()
+                                ._schema());
+    }
+
     /** The JSON Schema the request carries, as the SDK holds it on the 
response format. */
     private static String nativeSchemaPayload(ChatCompletionCreateParams 
params) {
         return params.responseFormat()
@@ -311,8 +342,8 @@ class AzureOpenAIChatModelConnectionTest {
 
         assertThat(request.responseFormat()).isPresent();
         ResponseFormatJsonSchema jsonSchema = 
request.responseFormat().get().asJsonSchema();
-        // The SDK derives the wire name from the class, so it identifies the 
schema without being
-        // equal to the class name.
+        // The wire name is derived from the class, so it identifies the 
schema without being equal
+        // to the class name.
         assertThat(jsonSchema.jsonSchema().name()).contains("Person");
         assertThat(jsonSchema.jsonSchema().strict()).contains(true);
         // Asserting the members rather than only the flags: a schema 
declaring no properties
@@ -323,10 +354,11 @@ class AzureOpenAIChatModelConnectionTest {
     }
 
     @Test
-    @DisplayName("A polymorphic member is sent as the discriminated union the 
SDK derives")
+    @DisplayName("A polymorphic member is sent as a discriminated union")
     void testPolymorphicMemberSchemaIsSent() {
-        // Jackson renders this member as an object declaring no properties, 
while the SDK derives
-        // the full union the provider accepts. Reading a Jackson-rendered 
schema here would refuse
+        // Jackson renders this member as an object declaring no properties, 
while the schema
+        // generator derives the full union the provider accepts. Reading a 
Jackson-rendered schema
+        // here would refuse
         // a request that works.
         ChatCompletionCreateParams request =
                 connection()
@@ -338,6 +370,30 @@ class AzureOpenAIChatModelConnectionTest {
                 .contains("kind={const=dog}");
     }
 
+    @Test
+    @DisplayName("The derived schema lists enum constants the way Jackson 
deserializes them")
+    void derivedSchemaFollowsJacksonEnumValues() throws Exception {
+        JsonNode status =
+                nativeSchema(
+                                connection()
+                                        .buildRequest(
+                                                userMessage(),
+                                                List.of(),
+                                                params("gpt-4o-mini"),
+                                                Ticket.class))
+                        .path("properties")
+                        .path("status");
+
+        // Strict mode confines the model to the listed values, 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 : status.path("enum")) {
+            statuses.add(MAPPER.treeToValue(value, Status.class));
+        }
+        assertThat(statuses).containsExactlyInAnyOrder(Status.values());
+    }
+
     @Test
     @DisplayName("A native request still targets the deployment, not the 
backing model")
     void testCapableNativeRequestStillTargetsTheDeployment() {
diff --git 
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnectionTest.java
 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnectionTest.java
index 58a75604..05cf7889 100644
--- 
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnectionTest.java
+++ 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnectionTest.java
@@ -18,8 +18,13 @@
 
 package org.apache.flink.agents.integrations.chatmodels.openai;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
 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.openai.core.ObjectMappers;
 import com.openai.errors.BadRequestException;
 import com.openai.models.ChatModel;
 import com.openai.models.ResponseFormatJsonSchema;
@@ -41,10 +46,12 @@ import org.junit.jupiter.api.Test;
 
 import java.io.IOException;
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.atomic.AtomicReference;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -60,13 +67,15 @@ class OpenAICompletionsConnectionTest {
 
     private static final ResourceContext NOOP = 
ResourceContext.fromGetResource((a, b) -> null);
 
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
     /** A representative POJO output schema. */
     public static class Person {
         public String name;
         public int age;
     }
 
-    /** A polymorphic member, which the SDK renders as a discriminated union. 
*/
+    /** A polymorphic member, which the derived schema states as a 
discriminated union. */
     @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
     @JsonSubTypes({
         @JsonSubTypes.Type(value = Dog.class, name = "dog"),
@@ -90,6 +99,65 @@ class OpenAICompletionsConnectionTest {
         public Pet pet;
     }
 
+    /**
+     * 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;
+    }
+
+    /** Enum constants deserialized from their {@code @JsonProperty} values. */
+    public enum Status {
+        @JsonProperty("in-progress")
+        IN_PROGRESS,
+        @JsonProperty("done")
+        DONE
+    }
+
+    /** Enum constants deserialized from their {@code @JsonValue} method. */
+    public enum Phase {
+        STARTED("started"),
+        FINISHED("finished");
+
+        private final String wire;
+
+        Phase(String wire) {
+            this.wire = wire;
+        }
+
+        @JsonValue
+        public String wire() {
+            return wire;
+        }
+    }
+
+    /** Output schema fixture with an {@code Optional} field and a nested 
object. */
+    public static class Draft {
+        public String title;
+        public Optional<String> note;
+        public Person author;
+    }
+
+    /** The JSON Schema the request carries, as a JSON tree. */
+    private static JsonNode nativeSchema(ChatCompletionCreateParams params) {
+        return ObjectMappers.jsonMapper()
+                .valueToTree(
+                        params.responseFormat()
+                                .orElseThrow()
+                                .asJsonSchema()
+                                .jsonSchema()
+                                ._schema());
+    }
+
+    private static List<String> textValues(JsonNode array) {
+        List<String> values = new ArrayList<>();
+        array.forEach(value -> values.add(value.asText()));
+        return values;
+    }
+
     /** The JSON Schema the request carries, as the SDK holds it on the 
response format. */
     private static String nativeSchemaPayload(ChatCompletionCreateParams 
params) {
         return params.responseFormat()
@@ -302,10 +370,11 @@ class OpenAICompletionsConnectionTest {
     }
 
     @Test
-    @DisplayName("A polymorphic member is sent as the discriminated union the 
SDK derives")
+    @DisplayName("A polymorphic member is sent as a discriminated union")
     void testPolymorphicMemberSchemaIsSent() {
-        // Jackson renders this member as an object declaring no properties, 
while the SDK derives
-        // the full union the provider accepts. Reading a Jackson-rendered 
schema here would refuse
+        // Jackson renders this member as an object declaring no properties, 
while the schema
+        // generator derives the full union the provider accepts. Reading a 
Jackson-rendered schema
+        // here would refuse
         // a request that works.
         ChatCompletionCreateParams request =
                 connection().buildRequest(userMessage(), List.of(), 
params("gpt-4o"), Owner.class);
@@ -316,6 +385,56 @@ class OpenAICompletionsConnectionTest {
                 .contains("kind={const=dog}");
     }
 
+    @Test
+    @DisplayName("The derived schema lists enum constants the way Jackson 
deserializes them")
+    void derivedSchemaFollowsJacksonEnumValues() throws Exception {
+        JsonNode properties =
+                nativeSchema(
+                                connection()
+                                        .buildRequest(
+                                                userMessage(),
+                                                List.of(),
+                                                params("gpt-4o"),
+                                                Ticket.class))
+                        .path("properties");
+
+        // Strict mode confines the model to the listed values, 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 derived schema requires every field and forbids extras 
at every level")
+    void derivedSchemaRequiresEveryFieldAndForbidsExtras() {
+        JsonNode schema =
+                nativeSchema(
+                        connection()
+                                .buildRequest(
+                                        userMessage(), List.of(), 
params("gpt-4o"), Draft.class));
+
+        // Strict mode rejects a schema that leaves a property out of the 
required set or admits
+        // additional properties, so the Optional field is required too and 
admits null instead.
+        assertThat(textValues(schema.path("required")))
+                .containsExactlyInAnyOrder("title", "note", "author");
+        
assertThat(textValues(schema.path("properties").path("note").path("type")))
+                .contains("null");
+        
assertThat(schema.path("additionalProperties").asBoolean(true)).isFalse();
+        JsonNode author = schema.path("properties").path("author");
+        
assertThat(textValues(author.path("required"))).containsExactlyInAnyOrder("name",
 "age");
+        
assertThat(author.path("additionalProperties").asBoolean(true)).isFalse();
+    }
+
     @Test
     @DisplayName("Native NOT applied when no output schema is supplied")
     void testNativeNotAppliedWhenSchemaNull() {

Reply via email to