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 5fb89f5c [api][integrations][java][python] Raise a clear error for an 
output schema that cannot be rendered (#1046)
5fb89f5c is described below

commit 5fb89f5ca40bc5233b49f9cd1e63ac51a886ac97
Author: Weiqing Yang <[email protected]>
AuthorDate: Mon Aug 31 00:18:17 2026 -0700

    [api][integrations][java][python] Raise a clear error for an output schema 
that cannot be rendered (#1046)
    
    Generated-by: Claude Code v2.1.53 (claude-opus-5[1m])
---
 .../apache/flink/agents/api/agents/ReActAgent.java |  35 ++++-
 .../api/chat/model/BaseChatModelConnection.java    |  21 +++
 .../flink/agents/api/agents/ReActAgentTest.java    |  89 ++++++++++++
 .../AnthropicChatModelConnectionTest.java          |  47 +++++++
 .../openai/AzureOpenAIChatModelConnectionTest.java |  57 ++++++++
 .../openai/OpenAICompletionsConnectionTest.java    |  56 ++++++++
 python/flink_agents/api/agents/react_agent.py      |  12 +-
 .../api/agents/tests/test_output_schema_render.py  | 156 +++++++++++++++++++++
 .../api/agents/tests/test_react_agent.py           | 101 +++++++++++++
 python/flink_agents/api/agents/types.py            |  65 ++++++++-
 python/flink_agents/api/chat_models/chat_model.py  |  34 ++++-
 .../chat_models/anthropic/anthropic_chat_model.py  |  27 +++-
 .../tests/test_anthropic_response_parsing.py       |  80 ++++++++++-
 .../chat_models/azure/azure_openai_chat_model.py   |  68 ++++++---
 .../test_azure_openai_native_structured_output.py  |  85 ++++++++++-
 .../chat_models/openai/openai_chat_model.py        |  10 +-
 .../tests/test_openai_native_structured_output.py  |  67 ++++++++-
 17 files changed, 968 insertions(+), 42 deletions(-)

diff --git 
a/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java 
b/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java
index 10ed457e..153556d6 100644
--- a/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java
+++ b/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java
@@ -69,14 +69,41 @@ public class ReActAgent extends Agent {
                 jsonSchema = outputSchema.toString();
                 outputSchema = new OutputSchema((RowTypeInfo) outputSchema);
             } else if (outputSchema instanceof Class) {
+                Class<?> schemaClass = (Class<?>) outputSchema;
                 try {
-                    jsonSchema = mapper.generateJsonSchema((Class<?>) 
outputSchema).toString();
-                } catch (JsonMappingException e) {
-                    throw new RuntimeException(e);
+                    jsonSchema = 
mapper.generateJsonSchema(schemaClass).getSchemaNode().toString();
+                } catch (JsonMappingException | IllegalArgumentException e) {
+                    // Both are reachable: a class whose getters disagree on a 
property name fails
+                    // the mapping, and one that would not serialize as a JSON 
object at all is
+                    // refused by the generator with an 
IllegalArgumentException naming no remedy.
+                    throw new IllegalArgumentException(
+                            String.format(
+                                    "Output schema %s cannot be rendered as a 
JSON Schema, so it"
+                                            + " cannot constrain the response. 
Use a schema whose"
+                                            + " fields are all 
JSON-Schema-renderable, or pass no"
+                                            + " output schema. Rendering it 
reported: %s",
+                                    schemaClass.getName(), e.getMessage()),
+                            e);
+                } catch (StackOverflowError e) {
+                    // The generator carries no cycle guard, so a class that 
reaches itself
+                    // through its own members recurses until the stack is 
gone. A separate clause
+                    // rather than another type on the union above because the 
error carries no
+                    // message to quote, so this case has to name the cause 
itself.
+                    throw new IllegalArgumentException(
+                            String.format(
+                                    "Output schema %s is self-referential, so 
rendering it as a"
+                                            + " JSON Schema does not terminate 
and it cannot"
+                                            + " constrain the response. Use a 
schema that does not"
+                                            + " refer back to itself, or pass 
no output schema.",
+                                    schemaClass.getName()),
+                            e);
                 }
             } else {
                 throw new IllegalArgumentException(
-                        "Output schema must be RowTypeInfo or Pojo class.");
+                        String.format(
+                                "Output schema %s is not supported. It must be 
a RowTypeInfo or"
+                                        + " a Pojo class.",
+                                outputSchema.getClass().getName()));
             }
             Prompt schemaPrompt =
                     Prompt.fromText(
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelConnection.java
 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelConnection.java
index b4200958..57d0a93b 100644
--- 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelConnection.java
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelConnection.java
@@ -93,6 +93,27 @@ public abstract class BaseChatModelConnection extends 
Resource {
      * schema into a native provider parameter overrides this overload, and 
reports its capability
      * via {@link #supportsNativeStructuredOutput(String)}.
      *
+     * <p>No connection translates an {@link 
org.apache.flink.agents.api.agents.OutputSchema}, and
+     * so a {@code RowTypeInfo}, natively, and what follows differs by 
connection. One that
+     * overrides this overload applies its native parameter only for a POJO 
{@link Class}, so it
+     * skips the {@code RowTypeInfo} and leaves the request unchanged, and the 
caller keeps the
+     * prompt-engineering fallback. One that does not override it rejects the 
{@code RowTypeInfo}
+     * through the default body above, which refuses every non-null schema 
alike. The skip is a
+     * deliberate, permanent fallback rather than a translation still to be 
written; the rejection
+     * is the separate case of a connection that could otherwise only drop the 
schema silently.
+     *
+     * <p>An overriding connection renders a POJO with its provider SDK's own 
schema generator, and
+     * a render failure is not reported here because those generators produce 
a schema for every
+     * class they are handed. The asymmetry with the Python side is imposed by 
the vendor libraries
+     * rather than chosen: Pydantic genuinely raises on a model it cannot 
express, and the Python
+     * connections wrap that. A schema the SDK does render is sent as rendered 
even when it declares
+     * no properties; whether such a document is usable is the receiving 
provider's to judge.
+     *
+     * <p>The ReAct prompt path renders through a different generator, 
Jackson, rather than through
+     * any provider SDK, and a POJO Jackson cannot render is rejected there 
rather than reaching the
+     * prompt. Because the two paths use different generators, that rejection 
says nothing about
+     * what a connection does with the same POJO.
+     *
      * @param messages the input chat messages
      * @param tools the tools can be called by the model
      * @param modelParams the additional arguments passed to the model
diff --git 
a/api/src/test/java/org/apache/flink/agents/api/agents/ReActAgentTest.java 
b/api/src/test/java/org/apache/flink/agents/api/agents/ReActAgentTest.java
index db237e73..12146236 100644
--- a/api/src/test/java/org/apache/flink/agents/api/agents/ReActAgentTest.java
+++ b/api/src/test/java/org/apache/flink/agents/api/agents/ReActAgentTest.java
@@ -20,12 +20,22 @@ package org.apache.flink.agents.api.agents;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.flink.agents.api.prompt.Prompt;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.apache.flink.agents.api.resource.ResourceType;
 import org.apache.flink.api.common.typeinfo.BasicTypeInfo;
 import org.apache.flink.api.common.typeinfo.TypeInformation;
 import org.apache.flink.api.java.typeutils.RowTypeInfo;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 
+import java.util.Map;
+import java.util.function.Function;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
 public class ReActAgentTest {
     @Test
     public void testOutputSchemaSerialization() throws JsonProcessingException 
{
@@ -41,4 +51,83 @@ public class ReActAgentTest {
         OutputSchema deserialized = mapper.readValue(json, OutputSchema.class);
         Assertions.assertEquals(typeInfo, deserialized.getSchema());
     }
+
+    @Test
+    @DisplayName("An agent built on a schema Jackson cannot render reports it 
with the cause kept")
+    public void testAgentRejectsSchemaThatCannotRender() {
+        assertThatThrownBy(() -> agentWithSchema(FieldLess.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("FieldLess")
+                .hasMessageContaining("cannot be rendered as a JSON Schema")
+                .hasCauseInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    @DisplayName("An agent built on a self-referential schema reports the 
self-reference")
+    public void testAgentRejectsSelfReferentialSchema() {
+        assertThatThrownBy(() -> agentWithSchema(SelfReferential.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("SelfReferential")
+                .hasMessageContaining("self-referential")
+                .hasCauseInstanceOf(StackOverflowError.class);
+    }
+
+    @Test
+    @DisplayName("An agent built on a member that renders to no properties 
still prompts with it")
+    public void testAgentAcceptsSchemaWithFieldLessMember() {
+        assertThat(schemaPromptOf(agentWithSchema(WithCallback.class)))
+                .contains("\"count\":{\"type\":\"integer\"}")
+                
.contains("\"callback\":{\"type\":\"object\",\"properties\":{}}");
+    }
+
+    @Test
+    @DisplayName("An agent built on a renderable schema prompts with its 
rendered JSON Schema")
+    public void testAgentAcceptsRenderableSchema() {
+        assertThat(schemaPromptOf(agentWithSchema(WithCount.class)))
+                .contains(
+                        
"{\"type\":\"object\",\"properties\":{\"count\":{\"type\":\"integer\"}}}");
+    }
+
+    @Test
+    @DisplayName("An output schema of neither supported kind reports the type 
it received")
+    public void testUnsupportedOutputSchemaTypeReportsTheType() {
+        assertThatThrownBy(() -> agentWithSchema("not-a-schema"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("java.lang.String")
+                .hasMessageContaining("must be a RowTypeInfo or a Pojo class");
+    }
+
+    private static ReActAgent agentWithSchema(Object outputSchema) {
+        return new ReActAgent(
+                
ResourceDescriptor.Builder.newBuilder("com.example.ChatModel").build(),
+                null,
+                outputSchema);
+    }
+
+    private static String schemaPromptOf(ReActAgent agent) {
+        Prompt schemaPrompt =
+                (Prompt)
+                        
agent.getResources().get(ResourceType.PROMPT).get("_default_schema_prompt");
+        return schemaPrompt.formatString(Map.of());
+    }
+
+    /** A class with no members at all, which Jackson refuses to render rather 
than rendering. */
+    public static class FieldLess {}
+
+    /** A member whose type carries no serializable state, so it renders to an 
empty object. */
+    public static class WithCallback {
+        public int count;
+        public Function<String, String> callback;
+    }
+
+    /** A member that renders to a concrete type. */
+    public static class WithCount {
+        public int count;
+    }
+
+    /** A class reachable from itself, which the generator recurses on until 
the stack is gone. */
+    public static class SelfReferential {
+        public String name;
+        public SelfReferential next;
+    }
 }
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 091b900b..003052da 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
@@ -24,6 +24,8 @@ import com.anthropic.models.messages.Model;
 import com.anthropic.models.messages.OutputConfig;
 import com.anthropic.models.messages.TextBlock;
 import com.anthropic.models.messages.Usage;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.flink.agents.api.chat.messages.ChatMessage;
@@ -263,6 +265,30 @@ class AnthropicChatModelConnectionTest {
         public String verdict;
     }
 
+    /** A polymorphic member, which the SDK renders as a discriminated union. 
*/
+    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
+    @JsonSubTypes({
+        @JsonSubTypes.Type(value = Dog.class, name = "dog"),
+        @JsonSubTypes.Type(value = Cat.class, name = "cat")
+    })
+    public abstract static class Pet {}
+
+    /** One arm of the {@link Pet} union. */
+    public static class Dog extends Pet {
+        public String bark;
+    }
+
+    /** The other arm of the {@link Pet} union. */
+    public static class Cat extends Pet {
+        public String meow;
+    }
+
+    /** Holds a polymorphic member. */
+    public static class Owner {
+        public String verdict;
+        public Pet pet;
+    }
+
     private static Map<String, Object> paramsWithModel(String model, Object 
jsonPrefill) {
         Map<String, Object> params = params(jsonPrefill);
         params.put("model", model);
@@ -291,6 +317,15 @@ class AnthropicChatModelConnectionTest {
                 .orElse(Set.of());
     }
 
+    /** 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
+                .outputConfig()
+                .flatMap(OutputConfig::format)
+                .map(format -> 
format.schema()._additionalProperties().toString())
+                .orElseThrow();
+    }
+
     @ParameterizedTest
     @ValueSource(strings = {"claude-sonnet-4-5", "claude-opus-4-6"})
     @DisplayName("a POJO schema on a capable model is sent as output_config")
@@ -382,6 +417,18 @@ class AnthropicChatModelConnectionTest {
                 
.isEqualTo(connection().supportsNativeStructuredOutput(CAPABLE_MODEL));
     }
 
+    @Test
+    @DisplayName("a polymorphic member is sent as the discriminated union the 
SDK derives")
+    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
+        // a request that works.
+        assertThat(nativeSchemaPayload(build(CAPABLE_MODEL, Owner.class, 
null)))
+                .contains("bark={type=string}")
+                .contains("meow={type=string}")
+                .contains("kind={const=dog}");
+    }
+
     @Test
     @DisplayName("a caller-supplied output_config wins and the schema falls 
back")
     void testCallerOutputConfigWinsOverSchema() {
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 6b7b2d3b..b47cbbdc 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,6 +18,8 @@
 
 package org.apache.flink.agents.integrations.chatmodels.openai;
 
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.openai.errors.BadRequestException;
 import com.openai.models.ChatModel;
@@ -84,6 +86,40 @@ class AzureOpenAIChatModelConnectionTest {
         public int age;
     }
 
+    /** A polymorphic member, which the SDK renders as a discriminated union. 
*/
+    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
+    @JsonSubTypes({
+        @JsonSubTypes.Type(value = Dog.class, name = "dog"),
+        @JsonSubTypes.Type(value = Cat.class, name = "cat")
+    })
+    public abstract static class Pet {}
+
+    /** One arm of the {@link Pet} union. */
+    public static class Dog extends Pet {
+        public String bark;
+    }
+
+    /** The other arm of the {@link Pet} union. */
+    public static class Cat extends Pet {
+        public String meow;
+    }
+
+    /** Holds a polymorphic member. */
+    public static class Owner {
+        public String name;
+        public Pet pet;
+    }
+
+    /** The JSON Schema the request carries, as the SDK holds it on the 
response format. */
+    private static String nativeSchemaPayload(ChatCompletionCreateParams 
params) {
+        return params.responseFormat()
+                .orElseThrow()
+                .asJsonSchema()
+                .jsonSchema()
+                ._schema()
+                .toString();
+    }
+
     private static AzureOpenAIChatModelConnection connection(String 
apiVersion) {
         ResourceDescriptor desc =
                 connectionDescriptor()
@@ -270,6 +306,27 @@ class AzureOpenAIChatModelConnectionTest {
         // 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
+        // would satisfy strict() and the derived name while constraining 
nothing at all.
+        assertThat(nativeSchemaPayload(request))
+                .contains("name={type=string}")
+                .contains("age={type=integer}");
+    }
+
+    @Test
+    @DisplayName("A polymorphic member is sent as the discriminated union the 
SDK derives")
+    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
+        // a request that works.
+        ChatCompletionCreateParams request =
+                connection()
+                        .buildRequest(userMessage(), List.of(), 
params("gpt-4o-mini"), Owner.class);
+
+        assertThat(nativeSchemaPayload(request))
+                .contains("bark={type=string}")
+                .contains("meow={type=string}")
+                .contains("kind={const=dog}");
     }
 
     @Test
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 269dee29..5204de2c 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,6 +18,8 @@
 
 package org.apache.flink.agents.integrations.chatmodels.openai;
 
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import com.openai.errors.BadRequestException;
 import com.openai.models.ResponseFormatJsonSchema;
 import com.openai.models.chat.completions.ChatCompletionCreateParams;
@@ -58,6 +60,40 @@ class OpenAICompletionsConnectionTest {
         public int age;
     }
 
+    /** A polymorphic member, which the SDK renders as a discriminated union. 
*/
+    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
+    @JsonSubTypes({
+        @JsonSubTypes.Type(value = Dog.class, name = "dog"),
+        @JsonSubTypes.Type(value = Cat.class, name = "cat")
+    })
+    public abstract static class Pet {}
+
+    /** One arm of the {@link Pet} union. */
+    public static class Dog extends Pet {
+        public String bark;
+    }
+
+    /** The other arm of the {@link Pet} union. */
+    public static class Cat extends Pet {
+        public String meow;
+    }
+
+    /** Holds a polymorphic member. */
+    public static class Owner {
+        public String name;
+        public Pet pet;
+    }
+
+    /** The JSON Schema the request carries, as the SDK holds it on the 
response format. */
+    private static String nativeSchemaPayload(ChatCompletionCreateParams 
params) {
+        return params.responseFormat()
+                .orElseThrow()
+                .asJsonSchema()
+                .jsonSchema()
+                ._schema()
+                .toString();
+    }
+
     private static OpenAICompletionsConnection connection() {
         ResourceDescriptor desc =
                 
ResourceDescriptor.Builder.newBuilder(OpenAICompletionsConnection.class.getName())
@@ -156,6 +192,26 @@ class OpenAICompletionsConnectionTest {
         assertThat(params.responseFormat()).isPresent();
         ResponseFormatJsonSchema jsonSchema = 
params.responseFormat().get().asJsonSchema();
         assertThat(jsonSchema.jsonSchema().strict()).contains(true);
+        // Asserting the members rather than only the flags: a schema 
declaring no properties
+        // would satisfy strict() and the derived name while constraining 
nothing at all.
+        assertThat(nativeSchemaPayload(params))
+                .contains("name={type=string}")
+                .contains("age={type=integer}");
+    }
+
+    @Test
+    @DisplayName("A polymorphic member is sent as the discriminated union the 
SDK derives")
+    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
+        // a request that works.
+        ChatCompletionCreateParams request =
+                connection().buildRequest(userMessage(), List.of(), 
params("gpt-4o"), Owner.class);
+
+        assertThat(nativeSchemaPayload(request))
+                .contains("bark={type=string}")
+                .contains("meow={type=string}")
+                .contains("kind={const=dog}");
     }
 
     @Test
diff --git a/python/flink_agents/api/agents/react_agent.py 
b/python/flink_agents/api/agents/react_agent.py
index 9f5f72df..d91e6b12 100644
--- a/python/flink_agents/api/agents/react_agent.py
+++ b/python/flink_agents/api/agents/react_agent.py
@@ -24,7 +24,7 @@ from pyflink.common import Row
 from pyflink.common.typeinfo import RowTypeInfo
 
 from flink_agents.api.agents.agent import STRUCTURED_OUTPUT, Agent
-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,
@@ -121,13 +121,21 @@ class ReActAgent(Agent):
             The schema should be RowTypeInfo or subclass of BaseModel. When 
user
             provide output schema, ReAct agent will add system prompt to 
instruct
             response format of llm, and add output parser according to the 
schema.
+
+        Raises:
+        ------
+        TypeError
+            If the schema is neither a RowTypeInfo nor a BaseModel subclass, 
or if a
+            BaseModel schema cannot be rendered as a JSON Schema.
         """
         super().__init__()
         self.add_resource(_DEFAULT_CHAT_MODEL, ResourceType.CHAT_MODEL, 
chat_model)
 
         if output_schema:
             if isinstance(output_schema, type) and issubclass(output_schema, 
BaseModel):
-                json_schema = output_schema.model_json_schema()
+                json_schema = render_output_schema(
+                    output_schema, lambda model: model.model_json_schema()
+                )
             elif isinstance(output_schema, RowTypeInfo):
                 json_schema = str(output_schema)
             else:
diff --git a/python/flink_agents/api/agents/tests/test_output_schema_render.py 
b/python/flink_agents/api/agents/tests/test_output_schema_render.py
new file mode 100644
index 00000000..5253fa6c
--- /dev/null
+++ b/python/flink_agents/api/agents/tests/test_output_schema_render.py
@@ -0,0 +1,156 @@
+################################################################################
+#  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.
+#################################################################################
+from typing import Any, Callable
+
+import pytest
+from pydantic import BaseModel
+from pydantic.errors import PydanticInvalidForJsonSchema
+
+from flink_agents.api.agents.types import render_output_schema
+
+
+def _render(model: type[BaseModel]) -> dict[str, Any]:
+    return model.model_json_schema()
+
+
+def _render_strict(model: type[BaseModel]) -> dict[str, Any]:
+    """Render the way the strict renderers do, closing every object."""
+    return _close_objects(model.model_json_schema())
+
+
+def _close_objects(node: Any) -> Any:
+    if isinstance(node, list):
+        return [_close_objects(item) for item in node]
+    if not isinstance(node, dict):
+        return node
+    closed = {key: _close_objects(value) for key, value in node.items()}
+    if closed.get("type") == "object" and "additionalProperties" not in closed:
+        closed["additionalProperties"] = False
+    return closed
+
+
+def _render_failing(model: type[BaseModel]) -> dict[str, Any]:
+    """Fail the way a vendor renderer does on a schema it cannot translate."""
+    msg = "unsupported by this provider"
+    raise ValueError(msg)
+
+
+class Unrenderable(BaseModel):
+    cb: Callable[[int], int]
+
+
+class NestsUnrenderable(BaseModel):
+    inner: Unrenderable
+
+
+class FieldLess(BaseModel):
+    pass
+
+
+class Renderable(BaseModel):
+    x: int
+
+
+class SelfReferential(BaseModel):
+    name: str
+    child: "SelfReferential | None" = None
+
+
+class MutualA(BaseModel):
+    b: "MutualB | None" = None
+
+
+class MutualB(BaseModel):
+    a: "MutualA | None" = None
+
+
+def test_unrenderable_member_raises_naming_the_model() -> None:
+    """A member no JSON Schema can express is reported as a failure to render 
it.
+
+    The wording separates that from a renderer refusing a model the model's own
+    render accepts, which is a different failure carrying a different remedy.
+    """
+    with pytest.raises(
+        TypeError, match="Unrenderable cannot be rendered as a JSON Schema"
+    ) as exc_info:
+        render_output_schema(Unrenderable, _render)
+
+    assert "pass no output schema" in str(exc_info.value)
+    assert isinstance(exc_info.value.__cause__, PydanticInvalidForJsonSchema)
+
+
+def test_nested_unrenderable_member_raises() -> None:
+    """The failure of a nested member surfaces as the same clear error."""
+    with pytest.raises(
+        TypeError, match="NestsUnrenderable cannot be rendered as a JSON 
Schema"
+    ):
+        render_output_schema(NestsUnrenderable, _render)
+
+
+def test_renderer_returning_no_document_raises() -> None:
+    """A renderer yielding something other than a document fails as a 
TypeError."""
+    with pytest.raises(TypeError, match="rather than a JSON Schema document"):
+        render_output_schema(Renderable, lambda model: "{}")
+
+
+def test_field_less_model_is_returned_as_rendered() -> None:
+    """A model declaring no fields renders and is returned as rendered.
+
+    Whether such a document is usable belongs to whatever consumes it, so it is
+    not refused here.
+    """
+    assert render_output_schema(FieldLess, _render) == _render(FieldLess)
+
+
[email protected]("model", [SelfReferential, MutualA], ids=["direct", 
"mutual"])
+def test_self_referential_model_is_returned_as_rendered(
+    model: type[BaseModel],
+) -> None:
+    """A model reachable from itself renders and is returned as rendered.
+
+    Pydantic emits the cycle as a ``$ref`` into ``$defs`` and terminates, so 
there
+    is nothing to report. It hoists the root into ``$defs`` and renders it as 
a bare
+    ``$ref`` only when the root itself lies on the cycle, which is what 
separates
+    these models from one that merely nests another.
+    """
+    schema = render_output_schema(model, _render)
+
+    assert "$ref" in schema
+    assert schema == _render(model)
+
+
+def test_return_value_is_the_renderer_output_not_the_model_schema() -> None:
+    """Callers receive the wire format they asked for, not the model's own 
schema."""
+    schema = render_output_schema(Renderable, _render_strict)
+
+    assert schema == _render_strict(Renderable)
+    assert schema != _render(Renderable)
+
+
+def test_renderer_failure_raises_chained() -> None:
+    """A renderer that fails is reported against the model, with the cause 
kept.
+
+    The wording separates that from a model no JSON Schema can express, which 
is a
+    different failure carrying a different remedy.
+    """
+    with pytest.raises(
+        TypeError, match="Renderable cannot be translated by the renderer in 
use"
+    ) as exc_info:
+        render_output_schema(Renderable, _render_failing)
+
+    assert isinstance(exc_info.value.__cause__, ValueError)
diff --git a/python/flink_agents/api/agents/tests/test_react_agent.py 
b/python/flink_agents/api/agents/tests/test_react_agent.py
new file mode 100644
index 00000000..c0efe063
--- /dev/null
+++ b/python/flink_agents/api/agents/tests/test_react_agent.py
@@ -0,0 +1,101 @@
+################################################################################
+#  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.
+#################################################################################
+from typing import Any, Callable
+
+import pytest
+from pydantic import BaseModel
+from pyflink.common.typeinfo import Types
+
+from flink_agents.api.agents.react_agent import (
+    _DEFAULT_SCHEMA_PROMPT,
+    ReActAgent,
+)
+from flink_agents.api.resource import ResourceDescriptor, ResourceType
+
+# Named rather than imported so building an agent needs no chat model on the 
path;
+# a descriptor records the class and resolves it only when the resource is 
created.
+_CHAT_MODEL_CLASS = (
+    
"flink_agents.integrations.chat_models.ollama_chat_model.OllamaChatModelSetup"
+)
+
+
+class Person(BaseModel):
+    """A representative BaseModel output schema."""
+
+    name: str
+    age: int
+
+
+class Unrenderable(BaseModel):
+    """A schema carrying a member that no JSON Schema can express."""
+
+    cb: Callable[[int], int]
+
+
+class FieldLess(BaseModel):
+    """A schema declaring no fields."""
+
+
+def _agent(output_schema: Any) -> ReActAgent:
+    return ReActAgent(
+        chat_model=ResourceDescriptor(
+            clazz=_CHAT_MODEL_CLASS, connection="ollama_connection", 
model="qwen3:8b"
+        ),
+        output_schema=output_schema,
+    )
+
+
+def _schema_prompt(agent: ReActAgent) -> str:
+    """The prompt text the agent derived from the output schema."""
+    return 
agent._resources[ResourceType.PROMPT][_DEFAULT_SCHEMA_PROMPT].template
+
+
+def _expected_prompt(rendered: Any) -> str:
+    return f"The final response should be json format, and match the schema 
{rendered}."
+
+
+def test_unrenderable_output_schema_raises_naming_the_model() -> None:
+    """A schema that cannot be rendered fails at construction, not at the 
provider."""
+    with pytest.raises(TypeError, match="Unrenderable cannot be rendered"):
+        _agent(Unrenderable)
+
+
+def test_field_less_output_schema_reaches_the_schema_prompt() -> None:
+    """A schema declaring no fields renders, and reaches the prompt as 
rendered."""
+    assert _schema_prompt(_agent(FieldLess)) == _expected_prompt(
+        FieldLess.model_json_schema()
+    )
+
+
+def test_renderable_output_schema_keeps_the_schema_prompt() -> None:
+    """An ordinary schema yields the prompt built from its rendered JSON 
Schema."""
+    assert _schema_prompt(_agent(Person)) == _expected_prompt(
+        Person.model_json_schema()
+    )
+
+
+def test_row_type_info_output_schema_keeps_the_prompt_fallback() -> None:
+    """A RowTypeInfo has no JSON Schema render and keeps its own prompt 
text."""
+    row_type = Types.ROW_NAMED(["name"], [Types.STRING()])
+    assert _schema_prompt(_agent(row_type)) == _expected_prompt(row_type)
+
+
+def test_unsupported_output_schema_type_reports_the_type() -> None:
+    """A schema of neither supported kind is rejected, named by the type 
received."""
+    with pytest.raises(TypeError, match=r"<class 'str'> is not supported"):
+        _agent("not-a-schema")
diff --git a/python/flink_agents/api/agents/types.py 
b/python/flink_agents/api/agents/types.py
index 2fd10b82..a0f5efff 100644
--- a/python/flink_agents/api/agents/types.py
+++ b/python/flink_agents/api/agents/types.py
@@ -16,7 +16,7 @@
 # limitations under the License.
 
#################################################################################
 import importlib
-from typing import Any
+from typing import Any, Callable
 
 from pydantic import BaseModel, ConfigDict, model_serializer, model_validator
 from pyflink.common.typeinfo import BasicType, BasicTypeInfo, RowTypeInfo
@@ -65,3 +65,66 @@ class OutputSchema(BaseModel):
                 module = importlib.import_module(output_schema["module"])
                 self["output_schema"] = getattr(module, output_schema["class"])
         return self
+
+
+def render_output_schema(
+    model: type[BaseModel], render: Callable[[type[BaseModel]], dict[str, Any]]
+) -> dict[str, Any]:
+    """Render an output schema, reporting a render failure clearly.
+
+    The caller supplies ``render`` because callers differ in the wire format 
they
+    need, and this module cannot depend on the renderers that produce it.
+
+    The model's own render runs first so that a model no JSON Schema can 
express is
+    reported as exactly that. ``render`` renders the model itself, so without 
that
+    first attempt the same failure would surface worded as a translation 
failure.
+    The call to ``render`` is wrapped in turn because a renderer can reject a 
model
+    the model's own render accepts, such as one carrying an untyped member that
+    renders to a document with no ``type``.
+
+    Whatever ``render`` produces is returned as produced. A document that 
declares
+    no fields is not refused here: whether it is usable belongs to the caller 
that
+    consumes it, and refusing it would fail a request that succeeds today.
+
+    Args:
+        model: The model class describing the shape the response must take.
+        render: Renders ``model`` in the wire format the caller expects.
+
+    Returns:
+        The document ``render`` produced.
+
+    Raises:
+        TypeError: If ``model`` has no JSON Schema, or if ``render`` fails or 
returns
+            something other than a document.
+    """
+    try:
+        # Run for its failure, not for its value.
+        model.model_json_schema()
+    except Exception as e:
+        msg = (
+            f"Output schema {model.__module__}.{model.__qualname__} cannot be"
+            " rendered as a JSON Schema, so it cannot constrain the response. 
Use a"
+            " schema whose fields are all JSON-Schema-renderable, or pass no 
output"
+            f" schema. Rendering it reported: {e}"
+        )
+        raise TypeError(msg) from e
+
+    try:
+        schema = render(model)
+    except Exception as e:
+        msg = (
+            f"Output schema {model.__module__}.{model.__qualname__} cannot be"
+            " translated by the renderer in use, so it cannot constrain the 
response."
+            " Use a schema whose fields are all JSON-Schema-renderable, or 
pass no"
+            f" output schema. The renderer reported: {e}"
+        )
+        raise TypeError(msg) from e
+    if not isinstance(schema, dict):
+        msg = (
+            f"Output schema {model.__module__}.{model.__qualname__} rendered 
to"
+            f" {type(schema).__name__} rather than a JSON Schema document, so 
it"
+            " cannot constrain the response. Supply a renderer that returns a 
JSON"
+            " Schema document, or pass no output schema."
+        )
+        raise TypeError(msg)
+    return schema
diff --git a/python/flink_agents/api/chat_models/chat_model.py 
b/python/flink_agents/api/chat_models/chat_model.py
index 528ae347..11cf6579 100644
--- a/python/flink_agents/api/chat_models/chat_model.py
+++ b/python/flink_agents/api/chat_models/chat_model.py
@@ -256,10 +256,36 @@ class BaseChatModelConnection(Resource, ABC):
             unconstrained response. This is framework-level execution 
metadata, and
             every implementation must declare it as a named parameter rather 
than let
             it fall into ``**kwargs``: ``**kwargs`` is forwarded to the 
provider SDK,
-            so a schema landing there would reach the request body. 
Implementations
-            without a native structured-output translation reject a 
non-``None`` value
-            via ``_reject_unsupported_output_schema``, so a caller that wants 
the
-            prompt-engineering fallback must pass ``None``.
+            so a schema landing there would reach the request body.
+
+            An ``OutputSchema`` wraps either a ``BaseModel`` subclass or a
+            ``RowTypeInfo``. No implementation translates a ``RowTypeInfo`` 
natively,
+            and what follows differs by implementation: one that translates a
+            ``BaseModel`` natively skips the ``RowTypeInfo`` and leaves the 
request
+            unchanged, so the caller keeps the prompt-engineering fallback; 
one with
+            no native translation at all rejects it, as described below. The 
skip is a
+            deliberate, permanent fallback, not a translation still to be 
written.
+
+            A ``BaseModel`` subclass is refused with a ``TypeError`` naming 
the schema
+            class and chaining the underlying error as its cause, both when it 
has no
+            JSON Schema at all and when it has one that this provider's 
renderer will
+            not accept. The second outcome is per-provider: a model with an 
untyped
+            member renders under Pydantic, and one provider's renderer takes 
it while
+            another refuses it. Neither is raised unless the request was going 
to
+            carry a native schema, since an implementation renders only once 
it has
+            decided to send one — so an unrenderable schema reports nothing 
when the
+            effective model is not one the implementation calls natively 
capable, or
+            when some other condition has already ruled the native branch out.
+
+            A ``BaseModel`` subclass that renders but declares no fields is 
sent as
+            rendered, leaving the receiving provider to accept or refuse it.
+
+            An implementation with no native structured-output translation at 
all is a
+            separate case, not the ``RowTypeInfo`` skip above: it rejects 
*every*
+            non-``None`` schema, ``RowTypeInfo`` included, via
+            ``_reject_unsupported_output_schema``, because it could otherwise 
only
+            drop the schema silently. A caller that wants the 
prompt-engineering
+            fallback from such an implementation must pass ``None``.
         **kwargs : Any
             Additional parameters passed to the model service (e.g., 
temperature,
             max_tokens, etc.)
diff --git 
a/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
 
b/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
index 2cf1d8da..563c3c21 100644
--- 
a/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
+++ 
b/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
@@ -24,7 +24,7 @@ from anthropic.types import MessageParam, TextBlockParam, 
ToolParam
 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,
@@ -207,6 +207,12 @@ def _native_output_config(output_schema: Any) -> Dict[str, 
Any] | None:
     Anthropic's format object carries only the schema and its type, so it 
shares no
     shape with the providers that nest the schema under a named, strict
     ``json_schema`` object and is built here rather than in a shared helper.
+
+    Raises ``TypeError`` if a ``BaseModel`` schema cannot be rendered, naming 
the
+    schema class rather than letting the renderer's own error, which names 
only its
+    internals, surface from a request the provider never sees. A schema that 
renders
+    but declares no fields is sent as it is, leaving the provider to accept or 
refuse
+    the document it receives.
     """
     if output_schema is None:
         return None
@@ -215,7 +221,12 @@ def _native_output_config(output_schema: Any) -> Dict[str, 
Any] | None:
     )
     if not (isinstance(model, type) and issubclass(model, BaseModel)):
         return None
-    return {"format": {"type": "json_schema", "schema": 
transform_schema(model)}}
+    return {
+        "format": {
+            "type": "json_schema",
+            "schema": render_output_schema(model, transform_schema),
+        }
+    }
 
 
 class AnthropicChatModelConnection(BaseChatModelConnection):
@@ -345,13 +356,19 @@ class 
AnthropicChatModelConnection(BaseChatModelConnection):
         if output_schema is not None and 
self.supports_native_structured_output(
             kwargs.get("model")
         ):
-            output_config = _native_output_config(output_schema)
             # An output_config already in kwargs is the caller being explicit 
about the
             # exact parameter this branch writes, so it is left alone and the 
schema
             # keeps the prompt-engineering fallback. Writing over it would 
drop the
             # caller's value with no error and no other trace.
-            if output_config is not None and "output_config" not in kwargs:
-                kwargs["output_config"] = output_config
+            #
+            # The schema is rendered inside that test rather than before it, 
because
+            # rendering raises on a schema it cannot express. Rendering one 
whose
+            # result this branch is about to discard would fail a request the 
caller
+            # had already steered away from the derived config.
+            if "output_config" not in kwargs:
+                output_config = _native_output_config(output_schema)
+                if output_config is not None:
+                    kwargs["output_config"] = output_config
 
         # JSON prefill appends a prefilled assistant "{" message to steer the 
model
         # into emitting a JSON document. It applies only when the request 
carries none
diff --git 
a/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_response_parsing.py
 
b/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_response_parsing.py
index 421ac3cd..22c98b3d 100644
--- 
a/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_response_parsing.py
+++ 
b/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_response_parsing.py
@@ -15,10 +15,11 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
-from typing import Any, Dict
+from typing import Any, Callable, Dict
 from unittest.mock import MagicMock
 
 import pytest
+from anthropic import transform_schema
 from anthropic.types import Message, TextBlock, ToolUseBlock, Usage
 from pydantic import BaseModel
 from pyflink.common.typeinfo import Types
@@ -162,6 +163,34 @@ class _Answer(BaseModel):
     verdict: str
 
 
+class _Unrenderable(BaseModel):
+    """A schema carrying a member that no JSON Schema can express."""
+
+    cb: Callable[[int], int]
+
+
+class _FieldLess(BaseModel):
+    """A schema declaring no fields, so it constrains nothing."""
+
+
+class _NestsFieldLess(BaseModel):
+    """A field-less schema one level down, reached through a ``$ref``."""
+
+    inner: _FieldLess
+
+
+class _MapsToFieldLess(BaseModel):
+    """A field-less schema reached through a map's ``additionalProperties``."""
+
+    m: Dict[str, _FieldLess]
+
+
+class _Labelled(BaseModel):
+    """A schema whose only member is a free-form map, a legitimate 
constraint."""
+
+    labels: Dict[str, str]
+
+
 # A model the provider documents native structured-output support for.
 #
 # Deliberately a 4.5-generation name, which is the only generation that is both
@@ -240,6 +269,39 @@ def 
test_native_output_config_applied_on_capable_model(model) -> None:
     assert set(output_config["format"]["schema"]["properties"]) == {"verdict"}
 
 
+def test_unrenderable_schema_raises_naming_the_model() -> None:
+    with pytest.raises(TypeError, match="_Unrenderable cannot be rendered"):
+        _request_kwargs(
+            model=_CAPABLE_MODEL,
+            output_schema=OutputSchema(output_schema=_Unrenderable),
+        )
+
+
[email protected]("schema", [_FieldLess, _NestsFieldLess, 
_MapsToFieldLess])
+def test_field_less_schema_is_accepted_and_sent_whole(schema) -> None:
+    # A schema declaring no fields renders, so the provider decides on it, not 
this
+    # connection. The document reaches the request exactly as rendered rather 
than
+    # being refused here. The nested cases carry the field-less model below 
the root,
+    # so the assertion covers the whole document rather than only its top 
level.
+    output_config = _request_kwargs(
+        model=_CAPABLE_MODEL, output_schema=OutputSchema(output_schema=schema)
+    )["output_config"]
+
+    assert output_config["format"]["schema"] == transform_schema(schema)
+
+
+def test_map_member_schema_is_accepted_and_sent_whole() -> None:
+    # This renderer rewrites a map member into an object with an empty 
properties. The
+    # rewritten document is what reaches the request, so the member survives 
the
+    # normalization rather than being dropped or flattened.
+    output_config = _request_kwargs(
+        model=_CAPABLE_MODEL, 
output_schema=OutputSchema(output_schema=_Labelled)
+    )["output_config"]
+
+    assert output_config["format"]["schema"] == transform_schema(_Labelled)
+    assert 
output_config["format"]["schema"]["properties"]["labels"]["properties"] == {}
+
+
 def test_native_output_config_not_applied_on_incapable_model() -> None:
     assert "output_config" not in _request_kwargs(
         model=_INCAPABLE_MODEL, 
output_schema=OutputSchema(output_schema=_Answer)
@@ -276,6 +338,22 @@ def test_caller_output_config_wins_over_schema() -> None:
     assert sent == caller_config
 
 
+def test_caller_output_config_wins_over_a_schema_that_cannot_be_rendered() -> 
None:
+    # The schema is rendered only when this branch will actually send the 
result. A
+    # render placed before the caller's value is honoured would refuse a 
request the
+    # caller had already steered away from the derived config, on the strength 
of a
+    # schema nothing was going to use.
+    caller_config = {"format": {"type": "json_schema", "schema": {"type": 
"object"}}}
+
+    sent = _request_kwargs(
+        model=_CAPABLE_MODEL,
+        output_schema=OutputSchema(output_schema=_Unrenderable),
+        output_config=caller_config,
+    )["output_config"]
+
+    assert sent == caller_config
+
+
 @pytest.mark.parametrize("model", _CAPABLE_MODELS)
 def test_capability_predicate_accepts_capable_models(model) -> None:
     assert _connection().supports_native_structured_output(model) is True
diff --git 
a/python/flink_agents/integrations/chat_models/azure/azure_openai_chat_model.py 
b/python/flink_agents/integrations/chat_models/azure/azure_openai_chat_model.py
index 50884bf6..8d264e8b 100644
--- 
a/python/flink_agents/integrations/chat_models/azure/azure_openai_chat_model.py
+++ 
b/python/flink_agents/integrations/chat_models/azure/azure_openai_chat_model.py
@@ -29,7 +29,7 @@ from openai.lib._pydantic import to_strict_json_schema
 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
 from flink_agents.api.chat_models.chat_model import (
     BaseChatModelConnection,
@@ -93,12 +93,14 @@ _MIN_STRUCTURED_OUTPUT_API_VERSION = "2024-08-01"
 _API_VERSION_DATE_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}", re.ASCII)
 
 
-def _native_response_format(output_schema: Any) -> Dict[str, Any] | None:
-    """Build the ``response_format`` for a native structured-output request.
+def _native_output_model(output_schema: Any) -> type[BaseModel] | None:
+    """The model a schema translates natively to, or ``None`` where none 
applies.
 
-    Returns ``None`` (leaving behavior unchanged) unless the schema is a 
``BaseModel``
-    subclass. A ``RowTypeInfo`` schema is skipped so it keeps the 
prompt-engineering
-    fallback.
+    ``None`` covers both no schema at all and a ``RowTypeInfo``, which has no 
native
+    translation 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.
     """
     if output_schema is None:
         return None
@@ -107,11 +109,30 @@ def _native_response_format(output_schema: Any) -> 
Dict[str, Any] | None:
     )
     if not (isinstance(model, type) and issubclass(model, BaseModel)):
         return None
+    return model
+
+
+def _native_response_format(output_schema: Any) -> Dict[str, Any] | None:
+    """Build the ``response_format`` for a native structured-output request.
+
+    Returns ``None`` (leaving behavior unchanged) unless the schema is a 
``BaseModel``
+    subclass. A ``RowTypeInfo`` schema is skipped so it keeps the 
prompt-engineering
+    fallback.
+
+    Raises ``TypeError`` if a ``BaseModel`` schema cannot be rendered, naming 
the
+    schema class rather than letting the renderer's own error, which names 
only its
+    internals, surface from a request the provider never sees. A schema that 
renders
+    but declares no fields is sent as it is, leaving the provider to accept or 
refuse
+    the document it receives.
+    """
+    model = _native_output_model(output_schema)
+    if model is None:
+        return None
     return {
         "type": "json_schema",
         "json_schema": {
             "name": model.__name__,
-            "schema": to_strict_json_schema(model),
+            "schema": render_output_schema(model, to_strict_json_schema),
             "strict": True,
         },
     }
@@ -302,22 +323,27 @@ class 
AzureOpenAIChatModelConnection(BaseChatModelConnection):
             and 
self.supports_native_structured_output(model_of_azure_deployment)
             and self._api_version_supports_structured_output()
         ):
+            native_model = _native_output_model(output_schema)
+            # Tested before the schema is rendered. A caller who supplies both 
a schema
+            # and a response_format has a conflict to resolve whatever the 
schema turns
+            # out to render to, and reporting a render failure instead would 
describe
+            # the wrong problem. The name is read off the model class, so this 
needs no
+            # rendered document.
+            caller_response_format = (
+                "response_format" in kwargs or "response_format" in 
additional_kwargs
+            )
+            if native_model is not None and caller_response_format:
+                msg = (
+                    f"The {native_model.__name__} output schema "
+                    f"is sent as response_format on deployment "
+                    f"'{azure_deployment}', so response_format must not also 
be "
+                    f"passed as a kwarg or in additional_kwargs. Remove that "
+                    f"value, or omit output_schema to set response_format "
+                    f"directly."
+                )
+                raise ValueError(msg)
             response_format = _native_response_format(output_schema)
             if response_format is not None:
-                caller_response_format = (
-                    "response_format" in kwargs
-                    or "response_format" in additional_kwargs
-                )
-                if caller_response_format:
-                    msg = (
-                        f"The {response_format['json_schema']['name']} output 
schema "
-                        f"is sent as response_format on deployment "
-                        f"'{azure_deployment}', so response_format must not 
also be "
-                        f"passed as a kwarg or in additional_kwargs. Remove 
that "
-                        f"value, or omit output_schema to set response_format "
-                        f"directly."
-                    )
-                    raise ValueError(msg)
                 kwargs["response_format"] = response_format
 
         response = self.client.chat.completions.create(
diff --git 
a/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_native_structured_output.py
 
b/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_native_structured_output.py
index cbaf084d..ade5c536 100644
--- 
a/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_native_structured_output.py
+++ 
b/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_native_structured_output.py
@@ -15,10 +15,11 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
-from typing import Any
+from typing import Any, Callable
 from unittest.mock import MagicMock
 
 import pytest
+from openai.lib._pydantic import to_strict_json_schema
 from pydantic import BaseModel
 from pyflink.common.typeinfo import Types
 
@@ -48,6 +49,34 @@ class Person(BaseModel):
     age: int
 
 
+class Unrenderable(BaseModel):
+    """A schema carrying a member that no JSON Schema can express."""
+
+    cb: Callable[[int], int]
+
+
+class FieldLess(BaseModel):
+    """A schema declaring no fields, so it constrains nothing."""
+
+
+class NestsFieldLess(BaseModel):
+    """A field-less schema one level down, reached through a ``$ref``."""
+
+    inner: FieldLess
+
+
+class MapsToFieldLess(BaseModel):
+    """A field-less schema reached through a map's ``additionalProperties``."""
+
+    m: dict[str, FieldLess]
+
+
+class Labelled(BaseModel):
+    """A schema whose only member is a free-form map, a legitimate 
constraint."""
+
+    labels: dict[str, str]
+
+
 ROW_TYPE = Types.ROW_NAMED(["name"], [Types.STRING()])
 
 
@@ -321,6 +350,23 @@ def 
test_caller_response_format_conflicts_with_native_schema(
     assert "Person" in str(excinfo.value)
 
 
+def test_caller_response_format_conflict_precedes_the_schema_render() -> None:
+    """A schema that cannot be rendered still reports the conflict, not the 
render.
+
+    The conflict stands whatever the schema would have rendered to, and it 
names the
+    two inputs the caller has to choose between. Rendering first would report a
+    different problem, on a value this branch was never going to send.
+    """
+    with pytest.raises(ValueError, match="Unrenderable") as excinfo:
+        _chat_with_caller_response_format(
+            _connection(),
+            model_of_azure_deployment="gpt-4o-mini",
+            in_additional_kwargs=False,
+            schema=Unrenderable,
+        )
+    assert "response_format must not also be passed" in str(excinfo.value)
+
+
 @pytest.mark.parametrize("in_additional_kwargs", [True, False])
 @pytest.mark.parametrize(
     ("api_version", "model_of_azure_deployment", "schema"),
@@ -429,3 +475,40 @@ def test_capability_predicate_reads_no_instance_state() -> 
None:
         AzureOpenAIChatModelConnection
     )
     assert uninitialized.supports_native_structured_output("gpt-5") is True
+
+
+def _chat_with_schema(conn: AzureOpenAIChatModelConnection, schema: Any) -> 
None:
+    conn.chat(
+        [ChatMessage(role=MessageRole.USER, content="hi")],
+        model=DEPLOYMENT,
+        model_of_azure_deployment="gpt-4o-mini",
+        output_schema=OutputSchema(output_schema=schema),
+    )
+
+
+def test_unrenderable_schema_raises_naming_the_model() -> None:
+    """A schema that cannot be rendered fails here rather than at the 
provider."""
+    with pytest.raises(TypeError, match="Unrenderable cannot be rendered"):
+        _chat_with_schema(_connection(), Unrenderable)
+
+
[email protected]("schema", [FieldLess, NestsFieldLess, 
MapsToFieldLess])
+def test_field_less_schema_is_accepted_and_sent_whole(schema: type[BaseModel]) 
-> None:
+    """A schema declaring no fields renders, so the provider decides on it, 
not us.
+
+    The document reaches the request exactly as rendered rather than being 
refused
+    here. The nested cases carry the field-less model below the root, so the
+    assertion covers the whole document rather than only its top level.
+    """
+    conn = _connection()
+    _chat_with_schema(conn, schema)
+    response_format = _create_call_kwargs(conn)["response_format"]
+    assert response_format["json_schema"]["schema"] == 
to_strict_json_schema(schema)
+
+
+def test_map_member_schema_is_accepted_and_sent_whole() -> None:
+    """A free-form map is a legitimate constraint and reaches the request 
intact."""
+    conn = _connection()
+    _chat_with_schema(conn, Labelled)
+    response_format = _create_call_kwargs(conn)["response_format"]
+    assert response_format["json_schema"]["schema"] == 
to_strict_json_schema(Labelled)
diff --git 
a/python/flink_agents/integrations/chat_models/openai/openai_chat_model.py 
b/python/flink_agents/integrations/chat_models/openai/openai_chat_model.py
index e810613f..587418dd 100644
--- a/python/flink_agents/integrations/chat_models/openai/openai_chat_model.py
+++ b/python/flink_agents/integrations/chat_models/openai/openai_chat_model.py
@@ -28,7 +28,7 @@ from openai.lib._pydantic import to_strict_json_schema
 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
 from flink_agents.api.chat_models.chat_model import (
     BaseChatModelConnection,
@@ -88,6 +88,12 @@ def _native_response_format(output_schema: Any) -> Dict[str, 
Any] | None:
     Returns ``None`` (leaving behavior unchanged) unless the schema is a 
``BaseModel``
     subclass. A ``RowTypeInfo`` schema is skipped so it keeps the 
prompt-engineering
     fallback.
+
+    Raises ``TypeError`` if a ``BaseModel`` schema cannot be rendered, naming 
the
+    schema class rather than letting the renderer's own error, which names 
only its
+    internals, surface from a request the provider never sees. A schema that 
renders
+    but declares no fields is sent as it is, leaving the provider to accept or 
refuse
+    the document it receives.
     """
     if output_schema is None:
         return None
@@ -100,7 +106,7 @@ def _native_response_format(output_schema: Any) -> 
Dict[str, Any] | None:
         "type": "json_schema",
         "json_schema": {
             "name": model.__name__,
-            "schema": to_strict_json_schema(model),
+            "schema": render_output_schema(model, to_strict_json_schema),
             "strict": True,
         },
     }
diff --git 
a/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
index 7132d804..d8b1d2c0 100644
--- 
a/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
+++ 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
@@ -15,10 +15,11 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
-from typing import Any
+from typing import Any, Callable
 from unittest.mock import MagicMock
 
 import pytest
+from openai.lib._pydantic import to_strict_json_schema
 from pydantic import BaseModel
 from pyflink.common.typeinfo import Types
 
@@ -38,6 +39,34 @@ class Person(BaseModel):
     age: int
 
 
+class Unrenderable(BaseModel):
+    """A schema carrying a member that no JSON Schema can express."""
+
+    cb: Callable[[int], int]
+
+
+class FieldLess(BaseModel):
+    """A schema declaring no fields, so it constrains nothing."""
+
+
+class NestsFieldLess(BaseModel):
+    """A field-less schema one level down, reached through a ``$ref``."""
+
+    inner: FieldLess
+
+
+class MapsToFieldLess(BaseModel):
+    """A field-less schema reached through a map's ``additionalProperties``."""
+
+    m: dict[str, FieldLess]
+
+
+class Labelled(BaseModel):
+    """A schema whose only member is a free-form map, a legitimate 
constraint."""
+
+    labels: dict[str, str]
+
+
 def _connection() -> OpenAIChatModelConnection:
     conn = OpenAIChatModelConnection(
         api_key="test-key", api_base_url="http://localhost";
@@ -203,3 +232,39 @@ def 
test_capability_predicate_accepts_capable_models(model: str) -> None:
 def test_capability_predicate_rejects_incapable_models(model: str | None) -> 
None:
     """The predicate rejects modality variants, incapable, unknown, and empty 
models."""
     assert _connection().supports_native_structured_output(model) is False
+
+
+def _chat_with_schema(conn: OpenAIChatModelConnection, schema: Any) -> None:
+    conn.chat(
+        [ChatMessage(role=MessageRole.USER, content="hi")],
+        model="gpt-4o",
+        output_schema=OutputSchema(output_schema=schema),
+    )
+
+
+def test_unrenderable_schema_raises_naming_the_model() -> None:
+    """A schema that cannot be rendered fails here rather than at the 
provider."""
+    with pytest.raises(TypeError, match="Unrenderable cannot be rendered"):
+        _chat_with_schema(_connection(), Unrenderable)
+
+
[email protected]("schema", [FieldLess, NestsFieldLess, 
MapsToFieldLess])
+def test_field_less_schema_is_accepted_and_sent_whole(schema: type[BaseModel]) 
-> None:
+    """A schema declaring no fields renders, so the provider decides on it, 
not us.
+
+    The document reaches the request exactly as rendered rather than being 
refused
+    here. The nested cases carry the field-less model below the root, so the
+    assertion covers the whole document rather than only its top level.
+    """
+    conn = _connection()
+    _chat_with_schema(conn, schema)
+    response_format = _create_call_kwargs(conn)["response_format"]
+    assert response_format["json_schema"]["schema"] == 
to_strict_json_schema(schema)
+
+
+def test_map_member_schema_is_accepted_and_sent_whole() -> None:
+    """A free-form map is a legitimate constraint and reaches the request 
intact."""
+    conn = _connection()
+    _chat_with_schema(conn, Labelled)
+    response_format = _create_call_kwargs(conn)["response_format"]
+    assert response_format["json_schema"]["schema"] == 
to_strict_json_schema(Labelled)

Reply via email to