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 3b8f6acb [integrations][watsonx] Support watsonx.ai native structured 
output (#1117)
3b8f6acb is described below

commit 3b8f6acbfa6b9d61ad7f22027c4abaa9ea7481ea
Author: Weiqing Yang <[email protected]>
AuthorDate: Mon Sep 14 20:03:44 2026 -0700

    [integrations][watsonx] Support watsonx.ai native structured output (#1117)
    
    Generated-by: Claude Code 2.1.260 (Claude Opus 5)
---
 integrations/chat-models/watsonx/pom.xml           |  11 +
 .../watsonx/WatsonxChatModelConnection.java        | 165 +++++++++-
 .../watsonx/WatsonxChatModelConnectionTest.java    | 317 ++++++++++++++++--
 .../watsonx/WatsonxChatModelLiveTest.java          |  32 ++
 .../tests/test_watsonx_native_structured_output.py | 358 +++++++++++++++++++++
 .../chat_models/watsonx/watsonx_chat_model.py      | 129 +++++++-
 6 files changed, 981 insertions(+), 31 deletions(-)

diff --git a/integrations/chat-models/watsonx/pom.xml 
b/integrations/chat-models/watsonx/pom.xml
index 864426ff..c71baae3 100644
--- a/integrations/chat-models/watsonx/pom.xml
+++ b/integrations/chat-models/watsonx/pom.xml
@@ -46,6 +46,17 @@ 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>
+        </dependency>
+
+        <dependency>
+            <groupId>com.github.victools</groupId>
+            <artifactId>jsonschema-module-jackson</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.flink</groupId>
             <artifactId>flink-annotations</artifactId>
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 1c1606c5..131d94cb 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
@@ -24,6 +24,13 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 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;
@@ -46,6 +53,7 @@ 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;
@@ -236,14 +244,65 @@ public class WatsonxChatModelConnection extends 
BaseChatModelConnection {
         return value;
     }
 
+    /**
+     * Whether watsonx.ai can constrain generation to a schema for {@code 
effectiveModel}.
+     *
+     * <p>Always {@code true}, and deliberately independent of the argument. 
The constraint is
+     * applied by the serving runtime, through vLLM guided decoding, rather 
than by a per-model
+     * capability. IBM states that chat API support requires the vLLM runtime, 
on its pages about
+     * inferencing <i>custom</i> foundation models; it does not state in so 
many words that its own
+     * provided models are served the same way. That last step rests instead 
on the chat request
+     * body exposing vLLM's guided-decoding parameters verbatim.
+     *
+     * <p>There is no allowlist because IBM publishes no per-model 
structured-output signal to key
+     * on: the foundation-model comparison table has columns for chat and tool 
interaction and none
+     * for structured output, and the model-specs API exposes no such flag. A 
list written here
+     * would encode a gate nobody documents and would report not-capable for 
models that do work.
+     *
+     * <p>This diverges from the base contract, which describes capability as 
model-dependent and
+     * requires an unrecognized model to report {@code false}. That rule 
guards against failing at
+     * the provider for a model whose capability is unknown; here capability 
is a property of the
+     * endpoint rather than of the model, so there is no unknown to guard 
against.
+     *
+     * <p>Reads no instance state, so capability stays answerable 
independently of how the
+     * connection was configured.
+     */
+    @Override
+    protected boolean supportsNativeStructuredOutput(String effectiveModel) {
+        return true;
+    }
+
     @Override
     public ChatMessage chat(
             List<ChatMessage> messages, List<Tool> tools, Map<String, Object> 
modelParams) {
+        return doChat(messages, tools, modelParams, null);
+    }
+
+    /**
+     * Translates {@code outputSchema} into watsonx.ai's native {@code 
response_format} field when
+     * it is a POJO {@link Class}. Any other schema form — notably a {@code 
RowTypeInfo} wrapped in
+     * {@code OutputSchema} — has no native translation here and leaves the 
request unconstrained,
+     * so that the prompt-engineering fallback still governs the response.
+     */
+    @Override
+    public ChatMessage chat(
+            List<ChatMessage> messages,
+            List<Tool> tools,
+            Map<String, Object> modelParams,
+            Object outputSchema) {
+        return doChat(messages, tools, modelParams, outputSchema);
+    }
+
+    private ChatMessage doChat(
+            List<ChatMessage> messages,
+            List<Tool> tools,
+            Map<String, Object> modelParams,
+            Object outputSchema) {
         try {
             final String modelName = (String) modelParams.get("model");
             final boolean extractReasoning =
                     Boolean.TRUE.equals(modelParams.get("extract_reasoning"));
-            final ObjectNode payload = buildPayload(messages, tools, 
modelParams);
+            final ObjectNode payload = buildPayload(messages, tools, 
modelParams, outputSchema);
             if (projectId != null && !projectId.isEmpty()) {
                 payload.put("project_id", projectId);
             } else {
@@ -397,10 +456,14 @@ public class WatsonxChatModelConnection extends 
BaseChatModelConnection {
     }
 
     @VisibleForTesting
-    static ObjectNode buildPayload(
-            List<ChatMessage> messages, List<Tool> tools, Map<String, Object> 
modelParams) {
+    ObjectNode buildPayload(
+            List<ChatMessage> messages,
+            List<Tool> tools,
+            Map<String, Object> modelParams,
+            Object outputSchema) {
         final ObjectNode payload = MAPPER.createObjectNode();
-        payload.put("model_id", (String) modelParams.get("model"));
+        final String modelName = (String) modelParams.get("model");
+        payload.put("model_id", modelName);
         payload.set("messages", convertMessages(messages));
 
         if (tools != null && !tools.isEmpty()) {
@@ -418,6 +481,40 @@ public class WatsonxChatModelConnection extends 
BaseChatModelConnection {
         @SuppressWarnings("unchecked")
         final Map<String, Object> additionalKwargs =
                 (Map<String, Object>) modelParams.get("additional_kwargs");
+
+        // Native structured output applies only for a POJO Class schema; any 
other schema form,
+        // such as a RowTypeInfo wrapped in OutputSchema, keeps the 
prompt-engineering fallback.
+        // The derived schema is a request field of its own rather than a 
sampling option, so it is
+        // written at the payload root. When no native translation applies the 
key stays absent
+        // rather than being written as a null, which would still be a present 
field on the wire.
+        //
+        // TODO(#912): the requested strategy is not visible here, so a 
request that explicitly
+        // asked for NATIVE cannot be told apart from one that merely resolved 
to it. Capability is
+        // unconditional on this connection, so the schema form is the only 
way through to an
+        // unconstrained response: a caller who asked for NATIVE and passed a 
schema this branch
+        // cannot translate gets one silently. Once strategy resolution is 
wired up, NATIVE must
+        // either bypass this re-check or fail explicitly.
+        if (outputSchema instanceof Class && 
supportsNativeStructuredOutput(modelName)) {
+            // A caller reaches the same payload field through either channel. 
Only the branch that
+            // actually sends a derived schema may reject the caller's value; 
every path that skips
+            // it leaves that value untouched. A null is not a conflict, 
because both write loops
+            // below skip null values and it therefore never reaches the 
payload.
+            final boolean callerSuppliedResponseFormat =
+                    modelParams.get("response_format") != null
+                            || (additionalKwargs != null
+                                    && additionalKwargs.get("response_format") 
!= null);
+            if (callerSuppliedResponseFormat) {
+                throw new IllegalArgumentException(
+                        "The "
+                                + ((Class<?>) outputSchema).getSimpleName()
+                                + " output schema is sent as response_format, 
so response_format"
+                                + " must not also be set in the model 
parameters or in"
+                                + " additional_kwargs. Remove that value, or 
omit the output"
+                                + " schema to set response_format directly.");
+            }
+            payload.set("response_format", toNativeResponseFormat((Class<?>) 
outputSchema));
+        }
+
         if (additionalKwargs != null) {
             final Set<String> collisions = new 
java.util.HashSet<>(additionalKwargs.keySet());
             collisions.retainAll(RESERVED_ADDITIONAL_KWARGS);
@@ -449,6 +546,66 @@ 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:
+    //
+    //   - 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.
+    //
+    // Two settings are deliberately absent:
+    //
+    //   - FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT is omitted for 
cross-language parity:
+    //     pydantic's plain schema does not emit additionalProperties: false, 
so emitting it on the
+    //     Java side alone would make the two documents disagree. The endpoint 
does not require it,
+    //     whether or not strict is set.
+    //   - DEFINITION_FOR_MAIN_SCHEMA is left off, which is victools' default. 
Turning it on only
+    //     moves the document root into $defs and makes the root a $ref, and 
nothing establishes
+    //     that watsonx benefits from either shape, so there is no reason to 
deviate. It does not
+    //     gate recursion: a self-referential type generates either way, as 
{"child": {"$ref": "#"}}
+    //     with the option off, and any type used twice is extracted into 
$defs regardless. A
+    //     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));
+        jsonSchema.put("strict", true);
+        return responseFormat;
+    }
+
     /**
      * Converts framework chat messages to the watsonx.ai (OpenAI-compatible) 
message format.
      *
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 f594e932..5dda6f7b 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,6 +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.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ArrayNode;
@@ -33,14 +35,18 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.IOException;
 import java.net.InetSocketAddress;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Function;
 import java.util.stream.Stream;
 
@@ -59,6 +65,42 @@ 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.
+     */
+    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;
+    }
 
     private static ResourceDescriptor descriptor(String url, String apiKey, 
String projectId) {
         ResourceDescriptor.Builder b =
@@ -115,7 +157,44 @@ class WatsonxChatModelConnectionTest {
         return connection.chat(
                 List.of(new ChatMessage(MessageRole.USER, "Hello!")),
                 List.of(),
-                Map.of("model", "ibm/granite-3-3-8b-instruct"));
+                Map.of("model", MODEL));
+    }
+
+    /** A connection that is never sent a request, for the payload-building 
tests. */
+    private static WatsonxChatModelConnection connection() {
+        return new WatsonxChatModelConnection(
+                descriptor("https://us-south.ml.cloud.ibm.com";, "test-key", 
"test-project"),
+                NOOP,
+                NO_ENVIRONMENT);
+    }
+
+    private static ObjectNode payloadFor(Object outputSchema) {
+        return payloadFor(Map.of("model", MODEL), outputSchema);
+    }
+
+    private static ObjectNode payloadFor(Map<String, Object> modelParams, 
Object outputSchema) {
+        return connection()
+                .buildPayload(
+                        List.of(new ChatMessage(MessageRole.USER, "Hello!")),
+                        List.of(),
+                        modelParams,
+                        outputSchema);
+    }
+
+    private static JsonNode derivedSchema(Object outputSchema) {
+        return 
payloadFor(outputSchema).path("response_format").path("json_schema").path("schema");
+    }
+
+    private static List<String> fieldNames(JsonNode objectNode) {
+        List<String> names = new ArrayList<>();
+        objectNode.fieldNames().forEachRemaining(names::add);
+        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}")
@@ -303,12 +382,10 @@ class WatsonxChatModelConnectionTest {
     @DisplayName("Model params are copied top-level into the payload")
     void testBuildPayload() {
         ObjectNode payload =
-                WatsonxChatModelConnection.buildPayload(
-                        List.of(new ChatMessage(MessageRole.USER, "Hello!")),
-                        List.of(),
+                payloadFor(
                         Map.of(
                                 "model",
-                                "ibm/granite-3-3-8b-instruct",
+                                MODEL,
                                 "temperature",
                                 0.5,
                                 "max_tokens",
@@ -318,9 +395,10 @@ class WatsonxChatModelConnectionTest {
                                 "extract_reasoning",
                                 true,
                                 "additional_kwargs",
-                                Map.of("top_p", 0.9)));
+                                Map.of("top_p", 0.9)),
+                        null);
 
-        
assertThat(payload.get("model_id").asText()).isEqualTo("ibm/granite-3-3-8b-instruct");
+        assertThat(payload.get("model_id").asText()).isEqualTo(MODEL);
         assertThat(payload.get("temperature").asDouble()).isEqualTo(0.5);
         assertThat(payload.get("max_tokens").asInt()).isEqualTo(256);
         assertThat(payload.get("top_p").asDouble()).isEqualTo(0.5);
@@ -332,14 +410,13 @@ class WatsonxChatModelConnectionTest {
 
         assertThatThrownBy(
                         () ->
-                                WatsonxChatModelConnection.buildPayload(
-                                        List.of(new 
ChatMessage(MessageRole.USER, "Hello!")),
-                                        List.of(),
+                                payloadFor(
                                         Map.of(
                                                 "model",
-                                                "ibm/granite-3-3-8b-instruct",
+                                                MODEL,
                                                 "additional_kwargs",
-                                                Map.of("temperature", 5.0))))
+                                                Map.of("temperature", 5.0)),
+                                        null))
                 .isInstanceOf(IllegalArgumentException.class)
                 .hasMessageContaining("additional_kwargs")
                 .hasMessageContaining("temperature");
@@ -348,14 +425,9 @@ class WatsonxChatModelConnectionTest {
                 List.of("model_id", "messages", "tools", "project_id", 
"space_id")) {
             assertThatThrownBy(
                             () ->
-                                    WatsonxChatModelConnection.buildPayload(
-                                            List.of(new 
ChatMessage(MessageRole.USER, "Hello!")),
-                                            List.of(),
-                                            Map.of(
-                                                    "model",
-                                                    
"ibm/granite-3-3-8b-instruct",
-                                                    requestOwnedField,
-                                                    "override")))
+                                    payloadFor(
+                                            Map.of("model", MODEL, 
requestOwnedField, "override"),
+                                            null))
                     .isInstanceOf(IllegalArgumentException.class)
                     .hasMessageContaining(requestOwnedField);
         }
@@ -672,4 +744,209 @@ class WatsonxChatModelConnectionTest {
         assertThat(function.get("name")).isEqualTo("add");
         assertThat(function.get("arguments")).isEqualTo(Map.of("a", 1, "b", 
2));
     }
+
+    @ParameterizedTest
+    @NullAndEmptySource
+    @ValueSource(
+            strings = {
+                "ibm/granite-3-3-8b-instruct",
+                "meta-llama/llama-3-3-70b-instruct",
+                "mistralai/mistral-large",
+                " "
+            })
+    @DisplayName("Capability is reported for any model, since the endpoint 
provides it")
+    void supportsNativeStructuredOutputIsUnconditional(String model) {
+        // Capability comes from the serving runtime the chat API requires 
rather than from the
+        // model, so a model allowlist here would silently drop back to the 
prompt fallback for
+        // anything the list had not caught up with. Null and blank are 
included because the
+        // answer does not depend on the argument at all.
+        
assertThat(connection().supportsNativeStructuredOutput(model)).isTrue();
+    }
+
+    @Test
+    @DisplayName("A POJO output schema is sent as a native json_schema 
response format")
+    void buildPayloadWritesResponseFormatForPojoSchema() {
+        ObjectNode payload = payloadFor(Report.class);
+
+        JsonNode responseFormat = payload.path("response_format");
+        
assertThat(responseFormat.path("type").asText()).isEqualTo("json_schema");
+        JsonNode jsonSchema = responseFormat.path("json_schema");
+        assertThat(jsonSchema.path("name").asText()).isEqualTo("Report");
+        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
+    @DisplayName("No output schema leaves the payload without a response 
format")
+    void buildPayloadOmitsResponseFormatWithoutSchema() {
+        ObjectNode payload = payloadFor(null);
+
+        // A null is still a present response_format field in the serialized 
body, so the key has
+        // to be absent rather than written as null.
+        assertThat(payload.has("response_format")).isFalse();
+    }
+
+    @Test
+    @DisplayName("A RowTypeInfo-shaped schema stays on the prompt fallback")
+    void buildPayloadOmitsResponseFormatForRowTypeInfo() {
+        // A RowTypeInfo schema arrives wrapped rather than as a bare POJO 
Class, so it must not
+        // activate native structured output. The wrapper cannot be built here 
because RowTypeInfo
+        // is not on this module's classpath; any non-Class schema object 
exercises the same gate.
+        // Deriving a schema from it must degrade to the fallback instead of 
failing the request.
+        Object nonClassSchema = "row<name STRING>";
+
+        ObjectNode payload = payloadFor(nonClassSchema);
+
+        assertThat(payload.has("response_format")).isFalse();
+    }
+
+    @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");
+    }
+
+    @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");
+    }
+
+    @Test
+    @DisplayName("The derived schema gives map values their own schema")
+    void derivedSchemaGivesMapValuesTheirSchema() {
+        JsonNode counts = 
derivedSchema(Report.class).path("properties").path("counts");
+
+        // A map without a value schema admits any value, so a response can 
satisfy the schema and
+        // still fail to deserialize into the declared value type at the 
caller.
+        assertThat(counts.path("additionalProperties").isObject()).isTrue();
+        
assertThat(counts.path("additionalProperties").path("type").asText()).isEqualTo("integer");
+    }
+
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("callerSuppliedResponseFormats")
+    @DisplayName("A caller response format alongside an output schema is 
rejected")
+    void chatWithSchemaAndCallerResponseFormatRaises(
+            String ignoredCaseName, Map<String, Object> modelParams) throws 
Exception {
+        HttpServer server = startServer();
+        server.createContext(
+                "/ml/v1/text/chat", exchange -> sendJson(exchange, 200, 
CHAT_RESPONSE));
+
+        try {
+            WatsonxChatModelConnection connection =
+                    new WatsonxChatModelConnection(
+                            stubDescriptor(baseUrl(server), false, 0), NOOP, 
NO_ENVIRONMENT);
+
+            // Both channels land in the same payload field as the derived 
schema, so letting
+            // either side win silently sends a format the caller never asked 
for, or drops the
+            // schema the agent depends on to parse the reply. The stub 
answers the request, so a
+            // guard that stops rejecting the combination fails here rather 
than hanging on a call
+            // to the real endpoint.
+            assertThatThrownBy(
+                            () ->
+                                    connection.chat(
+                                            List.of(new 
ChatMessage(MessageRole.USER, "Hello!")),
+                                            List.of(),
+                                            modelParams,
+                                            Report.class))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("response_format");
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    private static Stream<Arguments> callerSuppliedResponseFormats() {
+        return Stream.of(
+                Arguments.of("model params", callerFormatInModelParams()),
+                Arguments.of("additional_kwargs", 
callerFormatInAdditionalKwargs()));
+    }
+
+    private static Map<String, Object> callerFormatInModelParams() {
+        return Map.of("model", MODEL, "response_format", CALLER_FORMAT);
+    }
+
+    private static Map<String, Object> callerFormatInAdditionalKwargs() {
+        return Map.of(
+                "model", MODEL, "additional_kwargs", Map.of("response_format", 
CALLER_FORMAT));
+    }
+
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("skippedNativePathsWithCallerResponseFormat")
+    @DisplayName("A caller response format is forwarded when no schema is 
translated")
+    void callerResponseFormatSurvivesWhenNoSchemaIsSent(
+            String ignoredCaseName, Map<String, Object> modelParams, Object 
outputSchema) {
+        // response_format is on none of the reserved sets, so callers set it 
directly today. The
+        // rejection belongs to the branch that actually derives a schema: 
applied any wider it
+        // turns every one of those existing calls into an error.
+        ObjectNode payload = payloadFor(modelParams, outputSchema);
+
+        
assertThat(payload.path("response_format")).isEqualTo(MAPPER.valueToTree(CALLER_FORMAT));
+    }
+
+    private static Stream<Arguments> 
skippedNativePathsWithCallerResponseFormat() {
+        return Stream.of(
+                Arguments.of("no schema, model params", 
callerFormatInModelParams(), null),
+                Arguments.of(
+                        "no schema, additional_kwargs", 
callerFormatInAdditionalKwargs(), null),
+                // A schema the branch cannot translate skips it for the other 
reason, so the
+                // caller's value has to survive that arm too.
+                Arguments.of(
+                        "untranslatable schema, model params",
+                        callerFormatInModelParams(),
+                        "row<name STRING>"));
+    }
+
+    @Test
+    @DisplayName("The serialized request body carries response_format at the 
document root")
+    void serializedRequestBodyCarriesResponseFormatAtRoot() throws Exception {
+        HttpServer server = startServer();
+        AtomicReference<JsonNode> captured = new AtomicReference<>();
+        server.createContext(
+                "/ml/v1/text/chat",
+                exchange -> {
+                    captured.set(MAPPER.readTree(exchange.getRequestBody()));
+                    sendJson(exchange, 200, CHAT_RESPONSE);
+                });
+
+        try {
+            WatsonxChatModelConnection connection =
+                    new WatsonxChatModelConnection(
+                            stubDescriptor(baseUrl(server), false, 0), NOOP, 
NO_ENVIRONMENT);
+
+            connection.chat(
+                    List.of(new ChatMessage(MessageRole.USER, "Hello!")),
+                    List.of(),
+                    Map.of("model", MODEL),
+                    Report.class);
+
+            // The scope id is injected after the payload is built, so only 
the wire body proves
+            // that a schema handed to chat reaches the request at all, and 
that it sits at the
+            // root beside the other request fields rather than nested inside 
one of them.
+            JsonNode body = captured.get();
+            assertThat(body).isNotNull();
+            assertThat(body.has("response_format")).isTrue();
+            
assertThat(body.path("response_format").path("json_schema").path("name").asText())
+                    .isEqualTo("Report");
+        } finally {
+            server.stop(0);
+        }
+    }
 }
diff --git 
a/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelLiveTest.java
 
b/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelLiveTest.java
index 50197e18..5baaabc6 100644
--- 
a/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelLiveTest.java
+++ 
b/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelLiveTest.java
@@ -17,6 +17,7 @@
  */
 package org.apache.flink.agents.integrations.chatmodels.watsonx;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.flink.agents.api.chat.messages.ChatMessage;
 import org.apache.flink.agents.api.chat.messages.MessageRole;
 import org.apache.flink.agents.api.resource.ResourceContext;
@@ -42,6 +43,12 @@ class WatsonxChatModelLiveTest {
 
     private static final ResourceContext NOOP = 
ResourceContext.fromGetResource((a, b) -> null);
 
+    /** Flat output schema for the live structured-output call. */
+    public static class Answer {
+        public String verdict;
+        public int score;
+    }
+
     static boolean credentialsAvailable() {
         return isSet("WATSONX_URL")
                 && (isSet("WATSONX_API_KEY") || isSet("WATSONX_TOKEN"))
@@ -83,4 +90,29 @@ class WatsonxChatModelLiveTest {
         assertThat(response.getExtraArgs().get("promptTokens")).isNotNull();
         
assertThat(response.getExtraArgs().get("completionTokens")).isNotNull();
     }
+
+    @Test
+    @DisplayName("A POJO output schema comes back as content that deserializes 
into it")
+    void testChatWithOutputSchema() throws Exception {
+        ChatMessage response =
+                connection()
+                        .chat(
+                                List.of(
+                                        new ChatMessage(
+                                                MessageRole.USER,
+                                                "Rate the sentence \"the build 
is green\" and"
+                                                        + " report a verdict 
and a score.")),
+                                List.of(),
+                                Map.of("model", model(), "max_tokens", 200),
+                                Answer.class);
+
+        // This is the only check that the schema was applied rather than 
answered with prose. A
+        // reply that does not deserialize is a result to investigate rather 
than a diagnosis on
+        // its own: the schema may not have been enforced, or may not have 
described the type the
+        // caller passed, or the reply may carry a property the schema permits 
— no
+        // additionalProperties: false is sent — but this reader rejects.
+        assertThat(response.getContent()).isNotBlank();
+        Answer answer = new ObjectMapper().readValue(response.getContent(), 
Answer.class);
+        assertThat(answer.verdict).isNotNull();
+    }
 }
diff --git 
a/python/flink_agents/integrations/chat_models/watsonx/tests/test_watsonx_native_structured_output.py
 
b/python/flink_agents/integrations/chat_models/watsonx/tests/test_watsonx_native_structured_output.py
new file mode 100644
index 00000000..92a8ca18
--- /dev/null
+++ 
b/python/flink_agents/integrations/chat_models/watsonx/tests/test_watsonx_native_structured_output.py
@@ -0,0 +1,358 @@
+################################################################################
+#  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.
+#################################################################################
+import json
+import os
+from typing import Any, Callable, Dict, Tuple
+from unittest.mock import MagicMock
+
+import pytest
+from pydantic import BaseModel
+from pyflink.common.typeinfo import BasicTypeInfo, RowTypeInfo
+
+from flink_agents.api.agents.types import OutputSchema
+from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.integrations.chat_models.watsonx.watsonx_chat_model import (
+    DEFAULT_MODEL,
+    WatsonxChatModelConnection,
+)
+
+test_model = os.environ.get("WATSONX_CHAT_MODEL", DEFAULT_MODEL)
+credentials_available = (
+    "WATSONX_URL" in os.environ
+    and ("WATSONX_API_KEY" in os.environ or "WATSONX_TOKEN" in os.environ)
+) and ("WATSONX_PROJECT_ID" in os.environ or "WATSONX_SPACE_ID" in os.environ)
+
+
+class Report(BaseModel):
+    """Output schema fixture, shaped like the Java suite's fixture of the same 
name.
+
+    The shapes are held in step so the two languages can be compared: a plain 
field,
+    a map with a typed value, an omissible field, and a second plain field.
+    """
+
+    summary: str
+    counts: Dict[str, int]
+    note: str | None = None
+    total: int
+
+
+class Unrenderable(BaseModel):
+    """A schema carrying a member that no JSON Schema can express."""
+
+    cb: Callable[[int], int]
+
+
+class Answer(BaseModel):
+    """Flat output schema for the live structured-output call."""
+
+    verdict: str
+    score: int
+
+
+# What the Java suite pins for the Report shape. The two documents are 
compared on
+# property names, the required set and value types rather than byte for byte:
+# victools always emits $schema and never title, and pydantic is the reverse.
+JAVA_PROPERTY_NAMES = {"summary", "counts", "note", "total"}
+JAVA_REQUIRED = {"summary", "counts", "total"}
+
+CALLER_FORMAT = {"type": "json_object"}
+
+CHAT_RESPONSE = {
+    "id": "chatcmpl-1",
+    "model_id": test_model,
+    "choices": [
+        {
+            "index": 0,
+            "message": {"role": "assistant", "content": "{}"},
+            "finish_reason": "stop",
+        }
+    ],
+}
+
+
+def _connection() -> WatsonxChatModelConnection:
+    """A connection built from fake credentials, contacting nothing on its 
own."""
+    return WatsonxChatModelConnection(
+        url="https://us-south.ml.cloud.ibm.com";,
+        api_key="fake-key",
+        project_id="fake-project",
+    )
+
+
+def _mocked_connection(
+    monkeypatch: pytest.MonkeyPatch,
+) -> Tuple[WatsonxChatModelConnection, MagicMock]:
+    """A connection whose provider call is a mock, returned alongside it.
+
+    Assigning the private client is what keeps the call offline: the connection
+    builds a real one lazily on first use otherwise.
+    """
+    provider_model = MagicMock()
+    provider_model.chat.return_value = CHAT_RESPONSE
+    monkeypatch.setattr(
+        
"flink_agents.integrations.chat_models.watsonx.watsonx_chat_model.ModelInference",
+        MagicMock(return_value=provider_model),
+    )
+    connection = _connection()
+    connection._client = MagicMock()
+    return connection, provider_model
+
+
+def _sent_params(
+    monkeypatch: pytest.MonkeyPatch,
+    output_schema: OutputSchema | None = None,
+    **kwargs: Any,
+) -> Dict[str, Any]:
+    """The params dict one chat call hands to the provider."""
+    connection, provider_model = _mocked_connection(monkeypatch)
+    connection.chat(
+        [ChatMessage(role=MessageRole.USER, content="Hello!")],
+        output_schema=output_schema,
+        **kwargs,
+    )
+    return provider_model.chat.call_args.kwargs["params"] or {}
+
+
[email protected]("effective_model", [DEFAULT_MODEL, "no-such-model", 
None])
+def test_supports_native_structured_output_is_unconditional(
+    effective_model: str | None,
+) -> None:
+    """Capability is reported for any model, including one nothing recognizes.
+
+    The constraint is applied by the serving runtime rather than by the model, 
so a
+    gate on the model string would turn callers away from a path that works.
+    """
+    assert _connection().supports_native_structured_output(effective_model) is 
True
+
+
+def test_params_carry_response_format_for_basemodel_schema(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """A BaseModel schema reaches the provider as the endpoint's json_schema 
form.
+
+    The schema slot is the model's plain rendering. A stricter rendering would 
force
+    every field required and forbid additional properties, neither of which 
the Java
+    side emits.
+    """
+    params = _sent_params(monkeypatch, 
output_schema=OutputSchema(output_schema=Report))
+
+    assert params["response_format"] == {
+        "type": "json_schema",
+        "json_schema": {
+            "name": "Report",
+            "schema": Report.model_json_schema(),
+            "strict": True,
+        },
+    }
+
+
+def test_params_omit_response_format_without_schema(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """No schema leaves the key absent rather than present and empty."""
+    params = _sent_params(monkeypatch, temperature=0.5)
+
+    assert "response_format" not in params
+
+
+def test_row_type_info_schema_returns_and_sends_no_response_format(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """A RowTypeInfo schema is accepted and left to the prompt-engineering 
fallback.
+
+    Both halves are asserted. Reporting native capability exempts this 
connection
+    from the tree-wide rule that a connection rejects a schema it cannot 
translate,
+    so nothing else pins that a schema form with no native translation still 
returns
+    a response, and that no format is derived from it.
+    """
+    schema = OutputSchema(
+        output_schema=RowTypeInfo([BasicTypeInfo.INT_TYPE_INFO()], ["total"])
+    )
+    connection, provider_model = _mocked_connection(monkeypatch)
+
+    response = connection.chat(
+        [ChatMessage(role=MessageRole.USER, content="Hello!")],
+        output_schema=schema,
+        temperature=0.5,
+    )
+
+    assert response.role == MessageRole.ASSISTANT
+    assert "response_format" not in 
provider_model.chat.call_args.kwargs["params"]
+
+
+def test_unrenderable_model_reports_render_failure(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """A model with no JSON Schema is reported as that, naming the model.
+
+    Rendering through the shared helper is what produces this wording. Calling 
the
+    model's own rendering directly would surface an error naming neither the 
schema
+    nor a way out of it.
+    """
+    with pytest.raises(
+        TypeError, match="Unrenderable cannot be rendered as a JSON Schema"
+    ):
+        _sent_params(
+            monkeypatch, output_schema=OutputSchema(output_schema=Unrenderable)
+        )
+
+
[email protected](
+    "caller_kwargs",
+    [
+        pytest.param({"response_format": CALLER_FORMAT}, id="kwarg"),
+        pytest.param(
+            {"additional_kwargs": {"response_format": CALLER_FORMAT}},
+            id="additional_kwargs",
+        ),
+    ],
+)
+def test_schema_and_caller_response_format_raises(
+    monkeypatch: pytest.MonkeyPatch, caller_kwargs: Dict[str, Any]
+) -> None:
+    """A schema and a caller response format with a value cannot both be 
honored.
+
+    Both channels reach the same request field, so both are guarded. Choosing 
the
+    derived value would drop a setting the caller made deliberately, and 
choosing the
+    caller's would report the schema as applied when it was not.
+    """
+    with pytest.raises(
+        ValueError, match="Report output schema is sent as response_format"
+    ):
+        _sent_params(
+            monkeypatch,
+            output_schema=OutputSchema(output_schema=Report),
+            **caller_kwargs,
+        )
+
+
[email protected](
+    ("output_schema", "caller_kwargs"),
+    [
+        pytest.param(None, {"response_format": CALLER_FORMAT}, id="no schema, 
kwarg"),
+        pytest.param(
+            None,
+            {"additional_kwargs": {"response_format": CALLER_FORMAT}},
+            id="no schema, additional_kwargs",
+        ),
+        pytest.param(
+            OutputSchema(
+                output_schema=RowTypeInfo([BasicTypeInfo.INT_TYPE_INFO()], 
["total"])
+            ),
+            {"response_format": CALLER_FORMAT},
+            id="untranslatable schema, kwarg",
+        ),
+    ],
+)
+def test_caller_response_format_survives_when_no_schema_is_sent(
+    monkeypatch: pytest.MonkeyPatch,
+    output_schema: OutputSchema | None,
+    caller_kwargs: Dict[str, Any],
+) -> None:
+    """A caller response format is forwarded whenever no schema is derived.
+
+    response_format is on none of the reserved sets, so callers set it directly
+    today and the rejection belongs to the branch that actually derives a 
schema.
+    Applied any wider it turns those calls into errors. The branch is skipped 
for two
+    distinct reasons, no schema at all and a schema with no native 
translation, and
+    the caller's value has to survive both.
+    """
+    params = _sent_params(monkeypatch, output_schema=output_schema, 
**caller_kwargs)
+
+    assert params["response_format"] == CALLER_FORMAT
+
+
+def test_null_caller_response_format_is_not_a_conflict(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """A caller response format of None is replaced by the derived one, not 
refused.
+
+    Nothing the caller set survives to compete with the schema, so there is no
+    conflict to report and the request still carries the derived envelope.
+    """
+    params = _sent_params(
+        monkeypatch,
+        output_schema=OutputSchema(output_schema=Report),
+        response_format=None,
+    )
+
+    assert params["response_format"]["json_schema"]["name"] == "Report"
+
+
+def test_derived_schema_matches_java_semantically(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """The document sent from Python states the contract the Java one states.
+
+    A caller may reach the same model through either language, so the two must 
agree
+    on which properties exist, which of them the response must carry, and what 
a map
+    value has to be.
+    """
+    params = _sent_params(monkeypatch, 
output_schema=OutputSchema(output_schema=Report))
+    schema = params["response_format"]["json_schema"]["schema"]
+
+    assert set(schema["properties"]) == JAVA_PROPERTY_NAMES
+    assert set(schema["required"]) == JAVA_REQUIRED
+    assert schema["properties"]["summary"]["type"] == "string"
+    assert schema["properties"]["total"]["type"] == "integer"
+    assert schema["properties"]["counts"]["additionalProperties"]["type"] == 
"integer"
+
+
+def test_live_fixture_document_omits_additional_properties() -> None:
+    """The live call's schema states the same contract the Java recipe states.
+
+    A model config forbidding extras would render additionalProperties into the
+    document, so the two languages would send different contracts for one 
fixture.
+    Nothing else offline reads what the live call sends.
+    """
+    assert "additionalProperties" not in Answer.model_json_schema()
+
+
[email protected]
[email protected](
+    not credentials_available, reason="watsonx.ai credentials are not set"
+)
+def test_chat_with_output_schema() -> None:
+    """A schema comes back as content that parses into the model it described.
+
+    The reply's own keys are checked, not just that it parses, because nothing 
else
+    here would notice an extra one: no additionalProperties is sent, so the 
schema
+    permits it, and the reader drops a property it does not declare.
+
+    A reply that fails this is a result to investigate rather than a diagnosis 
on its
+    own: the schema may not have been applied, or may not have described the 
type the
+    caller passed.
+    """
+    response = WatsonxChatModelConnection().chat(
+        [
+            ChatMessage(
+                role=MessageRole.USER,
+                content='Rate the sentence "the build is green" and report a 
verdict'
+                " and a score.",
+            )
+        ],
+        output_schema=OutputSchema(output_schema=Answer),
+        model=test_model,
+        max_tokens=200,
+    )
+
+    assert response.content is not None
+    assert response.content.strip() != ""
+    parsed = json.loads(response.content)
+    assert set(parsed) == {"verdict", "score"}
+    assert Answer(**parsed).verdict is not None
diff --git 
a/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py 
b/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py
index b553e3f9..414ff6f9 100644
--- a/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py
+++ b/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py
@@ -28,10 +28,10 @@ import httpx
 from ibm_watsonx_ai import APIClient, Credentials
 from ibm_watsonx_ai.foundation_models import ModelInference
 from ibm_watsonx_ai.wml_client_error import ApiRequestFailure
-from pydantic import Field, PrivateAttr
+from pydantic import BaseModel, Field, PrivateAttr
 from typing_extensions import override
 
-from flink_agents.api.agents.types import OutputSchema
+from flink_agents.api.agents.types import OutputSchema, render_output_schema
 from flink_agents.api.chat_message import ChatMessage, MessageRole
 from flink_agents.api.chat_models.chat_model import (
     BaseChatModelConnection,
@@ -161,6 +161,49 @@ def _convert_to_watsonx_tool_call(tool_call: Dict[str, 
Any]) -> Dict[str, Any]:
     }
 
 
+def _native_output_model(
+    output_schema: OutputSchema | None,
+) -> type[BaseModel] | None:
+    """The model a schema translates natively to, or ``None`` where none 
applies.
+
+    ``None`` covers both no schema at all and a ``RowTypeInfo``, which has no 
native
+    translation here and keeps the prompt-engineering fallback.
+
+    Separate from the render below because the caller-conflict check needs to 
know
+    whether a schema will be sent, and under what name, before anything is 
rendered.
+    """
+    model = (
+        output_schema.output_schema if isinstance(output_schema, OutputSchema) 
else None
+    )
+    if not (isinstance(model, type) and issubclass(model, BaseModel)):
+        return None
+    return model
+
+
+def _native_response_format(
+    output_schema: OutputSchema | None,
+) -> Dict[str, Any] | None:
+    """Build the ``response_format`` for a native structured-output request.
+
+    Returns ``None``, leaving the request unchanged, unless the schema is a
+    ``BaseModel`` subclass.
+
+    Rendering goes through the shared helper, which raises ``TypeError`` 
naming the
+    schema class and the remedy when the model has no JSON Schema.
+    """
+    model = _native_output_model(output_schema)
+    if model is None:
+        return None
+    return {
+        "type": "json_schema",
+        "json_schema": {
+            "name": model.__name__,
+            "schema": render_output_schema(model, lambda m: 
m.model_json_schema()),
+            "strict": True,
+        },
+    }
+
+
 class WatsonxChatModelConnection(BaseChatModelConnection):
     """Connection to the IBM watsonx.ai chat API."""
 
@@ -332,6 +375,35 @@ class WatsonxChatModelConnection(BaseChatModelConnection):
                 time.sleep(delay)
                 attempt += 1
 
+    @override
+    def supports_native_structured_output(self, effective_model: str | None) 
-> bool:
+        """Whether watsonx.ai can constrain generation to a schema for the 
given model.
+
+        Always ``True``, and deliberately independent of the argument. The 
constraint
+        is applied by the serving runtime, through vLLM guided decoding, 
rather than by
+        a per-model capability. IBM states that chat API support requires the 
vLLM
+        runtime, on its pages about inferencing *custom* foundation models; it 
does not
+        state in so many words that its own provided models are served the 
same way.
+        That last step rests instead on the chat request body exposing vLLM's
+        guided-decoding parameters verbatim.
+
+        There is no allowlist because IBM publishes no per-model 
structured-output
+        signal to key on: the foundation-model comparison table has columns 
for chat
+        and tool interaction and none for structured output, and the 
model-specs API
+        exposes no such flag. A list written here would encode a gate nobody 
documents
+        and would report not-capable for models that do work.
+
+        This diverges from the base contract, which describes capability as
+        model-dependent and requires an unrecognized model to report 
``False``. That
+        rule guards against failing at the provider for a model whose 
capability is
+        unknown; here capability is a property of the endpoint rather than of 
the
+        model, so there is no unknown to guard against.
+
+        Reads no instance state, so capability stays answerable independently 
of how
+        the connection was configured.
+        """
+        return True
+
     def chat(
         self,
         messages: Sequence[ChatMessage],
@@ -341,16 +413,18 @@ class WatsonxChatModelConnection(BaseChatModelConnection):
     ) -> ChatMessage:
         """Process a sequence of messages, and return a response.
 
-        A non-``None`` ``output_schema`` is rejected: this connection has no 
native
-        structured-output translation, so callers stay on the 
prompt-engineering
-        fallback. Declaring the parameter keeps a caller-supplied schema out of
-        ``**kwargs``, which is forwarded to the provider SDK.
+        A ``BaseModel`` ``output_schema`` is sent as the ``response_format`` 
request
+        parameter. Any other schema form, notably a ``RowTypeInfo``, has no 
native
+        translation here and keeps the prompt-engineering fallback. Where the 
schema
+        is sent natively, a caller-supplied ``response_format`` carrying a 
value
+        conflicts with it and raises ``ValueError``. Declaring the parameter 
keeps a
+        caller-supplied schema out of ``**kwargs``, which is forwarded to the
+        provider SDK.
 
         When the response carries a finish reason, it is available verbatim as
         ``extra_args["finish_reason"]``; the key is absent when the provider
         reports none.
         """
-        self._reject_unsupported_output_schema(output_schema)
         model_name = kwargs.pop("model", DEFAULT_MODEL)
         extract_reasoning = bool(kwargs.pop("extract_reasoning", False))
         tool_choice = kwargs.pop("tool_choice", None)
@@ -373,6 +447,47 @@ class WatsonxChatModelConnection(BaseChatModelConnection):
             )
             raise ValueError(msg)
 
+        # Native structured output applies only for a BaseModel schema; any 
other
+        # schema form, such as a RowTypeInfo wrapped in OutputSchema, keeps the
+        # prompt-engineering fallback.
+        #
+        # TODO(#912): the requested strategy is not visible here, so a request 
that
+        # explicitly asked for NATIVE cannot be told apart from one that merely
+        # resolved to it. Capability is unconditional on this connection, so 
the schema
+        # form is the only way through to an unconstrained response: a caller 
who asked
+        # for NATIVE and passed a schema this branch cannot translate gets one
+        # silently. Once strategy resolution is wired up, NATIVE must either 
bypass
+        # this re-check or fail explicitly.
+        if output_schema is not None and 
self.supports_native_structured_output(
+            model_name
+        ):
+            native_model = _native_output_model(output_schema)
+            # A caller reaches the same request field through either channel, 
and both
+            # have already merged into request_params. Only the branch that 
sends a
+            # derived schema may reject the caller's value; every path that 
skips it
+            # leaves that value untouched. A None is not a conflict, because 
the
+            # assignment below replaces it before the request is built. Tested 
before
+            # the schema is rendered, so a caller who supplied both is told 
about the
+            # conflict rather than about a render failure; the name is read 
off the
+            # model class and needs no rendered document.
+            if (
+                native_model is not None
+                and request_params.get("response_format") is not None
+            ):
+                msg = (
+                    f"The {native_model.__name__} output schema is sent as"
+                    " response_format, so response_format must not also be 
passed as"
+                    " a kwarg or in additional_kwargs. Remove that value, or 
omit"
+                    " output_schema to set response_format directly."
+                )
+                raise ValueError(msg)
+            response_format = _native_response_format(output_schema)
+            if response_format is not None:
+                # The SDK merges params into the request body at its root, so 
the
+                # derived schema travels as the request field it is rather 
than as a
+                # sampling option.
+                request_params["response_format"] = response_format
+
         tool_specs: List[Dict[str, Any]] | None = (
             [to_openai_tool(metadata=tool.metadata) for tool in tools]
             if tools

Reply via email to