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 9a797b68 [api][python] Add explicit output schema parameter to the 
chat path (structured-output foundation) (#843)
9a797b68 is described below

commit 9a797b68f39a9d1ea5bdff178cee306d9bdbac61
Author: Weiqing Yang <[email protected]>
AuthorDate: Sat Jul 25 00:14:15 2026 -0700

    [api][python] Add explicit output schema parameter to the chat path 
(structured-output foundation) (#843)
---
 .../api/chat/model/BaseChatModelConnection.java    |  62 ++++++++
 .../agents/api/chat/model/BaseChatModelSetup.java  |  16 ++
 .../api/chat/model/StructuredOutputStrategy.java   | 112 ++++++++++++++
 .../agents/api/chat/model/BaseChatModelTest.java   | 120 +++++++++++++++
 python/flink_agents/api/chat_models/chat_model.py  | 162 +++++++++++++++++++-
 .../api/chat_models/tests/test_chat_model_base.py  | 134 +++++++++++++++-
 .../e2e_tests_integration/mock_chat_model_agent.py |  10 +-
 .../tool_parameter_injection_agent.py              |  10 +-
 .../chat_models/anthropic/anthropic_chat_model.py  |  11 +-
 .../chat_models/azure/azure_openai_chat_model.py   |   8 +
 .../integrations/chat_models/ollama_chat_model.py  |  11 +-
 .../chat_models/openai/openai_chat_model.py        |   8 +
 .../tests/test_output_schema_param_declared.py     | 168 +++++++++++++++++++++
 .../integrations/chat_models/tongyi_chat_model.py  |  11 +-
 .../flink_agents/runtime/java/java_chat_model.py   |  11 +-
 15 files changed, 842 insertions(+), 12 deletions(-)

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 5181073a..b4200958 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
@@ -25,6 +25,8 @@ import 
org.apache.flink.agents.api.resource.ResourceDescriptor;
 import org.apache.flink.agents.api.resource.ResourceType;
 import org.apache.flink.agents.api.tools.Tool;
 
+import javax.annotation.Nullable;
+
 import java.util.List;
 import java.util.Map;
 
@@ -45,6 +47,26 @@ public abstract class BaseChatModelConnection extends 
Resource {
         return ResourceType.CHAT_MODEL_CONNECTION;
     }
 
+    /**
+     * Whether this connection can apply the provider's native 
structured-output API for the given
+     * model.
+     *
+     * <p>Capability is <b>model-dependent</b>, not connection-wide: a single 
provider connection
+     * commonly serves both models that accept a native schema parameter and 
models that do not. It
+     * is therefore evaluated against the <i>effective</i> model at 
request-build time — the model
+     * actually being called, which per-request parameters may override.
+     *
+     * <p>The default {@code false} keeps a connection on the 
prompt-engineering fallback. An
+     * unrecognized model must report {@code false} so that it degrades to the 
fallback rather than
+     * failing at the provider.
+     *
+     * @param effectiveModel the model the request will be issued against, may 
be null
+     * @return true if a schema can be applied natively for {@code 
effectiveModel}
+     */
+    protected boolean supportsNativeStructuredOutput(String effectiveModel) {
+        return false;
+    }
+
     /**
      * Process a chat request and return a chat response.
      *
@@ -55,4 +77,44 @@ public abstract class BaseChatModelConnection extends 
Resource {
      */
     public abstract ChatMessage chat(
             List<ChatMessage> messages, List<Tool> tools, Map<String, Object> 
modelParams);
+
+    /**
+     * Process a chat request that carries an output schema, and return a chat 
response.
+     *
+     * <p>{@code outputSchema} is framework-level execution metadata, kept off 
{@code modelParams}
+     * so that it can never reach a provider SDK request as a generation 
parameter. It is either a
+     * POJO {@link Class} or an {@link 
org.apache.flink.agents.api.agents.OutputSchema} (a {@code
+     * RowTypeInfo} wrapper); the two cases are distinguished by the 
connection that consumes it.
+     *
+     * <p>A schema must not be handed to a connection that has no native 
translation for it: this
+     * default implementation rejects a non-null {@code outputSchema} rather 
than dropping it, so an
+     * unconstrained response can never be mistaken for a schema-conforming 
one. A null {@code
+     * outputSchema} delegates to {@link #chat(List, List, Map)}. A connection 
that does translate a
+     * schema into a native provider parameter overrides this overload, and 
reports its capability
+     * via {@link #supportsNativeStructuredOutput(String)}.
+     *
+     * @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
+     * @param outputSchema the schema the response should conform to, or null 
for an unconstrained
+     *     response
+     * @return the chat response containing model outputs
+     * @throws UnsupportedOperationException if {@code outputSchema} is 
non-null and this connection
+     *     has no native structured-output translation
+     */
+    public ChatMessage chat(
+            List<ChatMessage> messages,
+            List<Tool> tools,
+            Map<String, Object> modelParams,
+            @Nullable Object outputSchema) {
+        if (outputSchema != null) {
+            throw new UnsupportedOperationException(
+                    getClass().getName()
+                            + " has no native structured-output translation, 
so it cannot honor"
+                            + " the given output schema. Override chat(List, 
List, Map, Object) to"
+                            + " translate the schema natively, or pass no 
schema so the caller"
+                            + " applies the prompt-engineering fallback.");
+        }
+        return chat(messages, tools, modelParams);
+    }
 }
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetup.java
 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetup.java
index 34b2b299..6c59f6ef 100644
--- 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetup.java
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetup.java
@@ -48,6 +48,7 @@ public abstract class BaseChatModelSetup extends Resource {
     @Nullable protected String skillDiscoveryPrompt;
     protected List<String> allowedCommands;
     protected List<String> allowedScriptDirs;
+    protected StructuredOutputStrategy structuredOutputStrategy;
 
     @Nullable protected BaseChatModelConnection connection;
     protected final List<Tool> tools = new ArrayList<>();
@@ -67,6 +68,10 @@ public abstract class BaseChatModelSetup extends Resource {
                 declaredScriptDirs == null
                         ? new ArrayList<>()
                         : new ArrayList<>(declaredScriptDirs);
+        this.structuredOutputStrategy =
+                StructuredOutputStrategy.fromArgument(
+                        descriptor.getArgument("structured_output_strategy"),
+                        StructuredOutputStrategy.AUTO);
     }
 
     /**
@@ -219,4 +224,15 @@ public abstract class BaseChatModelSetup extends Resource {
     public List<String> getAllowedScriptDirs() {
         return allowedScriptDirs;
     }
+
+    /**
+     * The configured intent about how an output schema should be applied, 
defaulting to {@link
+     * StructuredOutputStrategy#AUTO}. Whether native structured output is 
actually applied combines
+     * this policy with the connection's model-dependent capability.
+     *
+     * @return the structured output strategy
+     */
+    public StructuredOutputStrategy getStructuredOutputStrategy() {
+        return structuredOutputStrategy;
+    }
 }
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/StructuredOutputStrategy.java
 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/StructuredOutputStrategy.java
new file mode 100644
index 00000000..4026d199
--- /dev/null
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/StructuredOutputStrategy.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.api.chat.model;
+
+import java.util.Locale;
+
+/**
+ * User intent about how an output schema should be applied to a chat request.
+ *
+ * <p>This expresses <b>policy</b> only. Whether a connection <i>can</i> apply 
the provider's native
+ * structured-output API is a separate, model-dependent <b>capability</b> 
question answered by
+ * {@link BaseChatModelConnection#supportsNativeStructuredOutput(String)}. 
Policy and capability are
+ * combined at request-build time.
+ */
+public enum StructuredOutputStrategy {
+    /**
+     * Use the provider's native structured-output API when the effective 
model is capable of it,
+     * and fall back to prompt engineering otherwise. This is the default.
+     */
+    AUTO,
+
+    /**
+     * Always use the provider's native structured-output API, without 
consulting the capability
+     * predicate.
+     */
+    NATIVE,
+
+    /**
+     * Never use the provider's native structured-output API; rely on prompt 
engineering alone. This
+     * matches the behavior of connections that have no native translation.
+     */
+    PROMPT;
+
+    /**
+     * Resolves this policy against a connection's model-dependent capability 
into whether the
+     * provider's native structured-output API should be used.
+     *
+     * <ul>
+     *   <li>{@code AUTO} defers to {@code modelCapable}: native when the 
effective model can, else
+     *       the prompt-engineering fallback.
+     *   <li>{@code NATIVE} always resolves to native, ignoring {@code 
modelCapable}, so an explicit
+     *       user intent surfaces a provider error rather than silently 
degrading.
+     *   <li>{@code PROMPT} never resolves to native.
+     * </ul>
+     *
+     * @param modelCapable whether the connection reports the effective model 
as natively capable
+     * @return true if native structured output should be applied
+     */
+    public boolean resolvesToNative(boolean modelCapable) {
+        switch (this) {
+            case NATIVE:
+                return true;
+            case PROMPT:
+                return false;
+            case AUTO:
+            default:
+                return modelCapable;
+        }
+    }
+
+    /**
+     * Resolves a strategy from a descriptor argument, which may arrive either 
as a {@code
+     * StructuredOutputStrategy} or — across the Python bridge, where 
arguments are carried as JSON
+     * — as its case-insensitive name.
+     *
+     * @param value the raw descriptor argument, may be null
+     * @param defaultValue the strategy to use when {@code value} is null
+     * @return the resolved strategy
+     * @throws IllegalArgumentException if {@code value} is neither null, a 
{@code
+     *     StructuredOutputStrategy}, nor the name of one
+     */
+    public static StructuredOutputStrategy fromArgument(
+            Object value, StructuredOutputStrategy defaultValue) {
+        if (value == null) {
+            return defaultValue;
+        }
+        if (value instanceof StructuredOutputStrategy) {
+            return (StructuredOutputStrategy) value;
+        }
+        if (value instanceof String) {
+            try {
+                return valueOf(((String) value).toUpperCase(Locale.ROOT));
+            } catch (IllegalArgumentException e) {
+                throw new IllegalArgumentException(
+                        String.format(
+                                "Unknown structured output strategy '%s'. 
Expected one of: AUTO, NATIVE, PROMPT.",
+                                value),
+                        e);
+            }
+        }
+        throw new IllegalArgumentException(
+                String.format(
+                        "Unsupported structured output strategy type '%s'. 
Expected a StructuredOutputStrategy or its name.",
+                        value.getClass().getName()));
+    }
+}
diff --git 
a/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelTest.java
 
b/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelTest.java
index 43f8c8b0..d27d79cb 100644
--- 
a/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelTest.java
+++ 
b/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelTest.java
@@ -237,6 +237,7 @@ class BaseChatModelTest {
     /** Connection that captures the messages passed to it for assertions. */
     private static class RecordingConnection extends BaseChatModelConnection {
         List<ChatMessage> capturedMessages;
+        Map<String, Object> capturedModelParams;
 
         RecordingConnection() {
             super(
@@ -249,6 +250,7 @@ class BaseChatModelTest {
         public ChatMessage chat(
                 List<ChatMessage> messages, List<Tool> tools, Map<String, 
Object> modelParams) {
             this.capturedMessages = new ArrayList<>(messages);
+            this.capturedModelParams = new HashMap<>(modelParams);
             return new ChatMessage(MessageRole.ASSISTANT, "ok");
         }
     }
@@ -320,6 +322,124 @@ class BaseChatModelTest {
         assertEquals("tool result", 
connection.capturedMessages.get(1).getContent());
     }
 
+    @Test
+    @DisplayName("Default chat() overload rejects an outputSchema it cannot 
translate")
+    void testDefaultChatOverloadRejectsOutputSchema() {
+        RecordingConnection connection = new RecordingConnection();
+
+        // Dropping the schema instead would return an unconstrained response 
that the
+        // caller has no way to tell apart from a schema-conforming one.
+        assertThrows(
+                UnsupportedOperationException.class,
+                () ->
+                        connection.chat(
+                                List.of(new ChatMessage(MessageRole.USER, 
"hi")),
+                                List.of(),
+                                new HashMap<>(),
+                                new Object()));
+
+        // The rejection has to precede the delegation: a delegate-then-throw 
ordering
+        // would still issue a real provider request before failing.
+        assertNull(connection.capturedMessages);
+    }
+
+    @Test
+    @DisplayName("Default chat() overload delegates to the 3-arg chat() for a 
null outputSchema")
+    void testDefaultChatOverloadDelegatesForNullOutputSchema() {
+        RecordingConnection connection = new RecordingConnection();
+        Map<String, Object> modelParams = new HashMap<>();
+        modelParams.put("temperature", 0.5);
+
+        ChatMessage response =
+                connection.chat(
+                        List.of(new ChatMessage(MessageRole.USER, "hi")),
+                        List.of(),
+                        modelParams,
+                        null);
+
+        // The 3-arg chat() ran (it is what produces "ok") and the overload 
added nothing
+        // to modelParams that could travel on to a provider SDK request.
+        assertEquals("ok", response.getContent());
+        assertEquals(Map.of("temperature", 0.5), 
connection.capturedModelParams);
+    }
+
+    @Test
+    @DisplayName("Default capability predicate reports no native structured 
output for any model")
+    void testDefaultCapabilityPredicateIsFalse() {
+        RecordingConnection connection = new RecordingConnection();
+
+        assertFalse(connection.supportsNativeStructuredOutput("gpt-4o"));
+        
assertFalse(connection.supportsNativeStructuredOutput("gpt-3.5-turbo"));
+        assertFalse(connection.supportsNativeStructuredOutput(null));
+    }
+
+    @Test
+    @DisplayName("Structured-output strategy defaults to AUTO when the 
descriptor omits it")
+    void testStructuredOutputStrategyDefaultsToAuto() {
+        RecordingChatModelSetup setup =
+                new RecordingChatModelSetup(new RecordingConnection(), null);
+
+        assertEquals(StructuredOutputStrategy.AUTO, 
setup.getStructuredOutputStrategy());
+    }
+
+    @Test
+    @DisplayName("Structured-output strategy defaults to AUTO when the 
descriptor argument is null")
+    void testStructuredOutputStrategyDefaultsToAutoForNullArgument() {
+        // A descriptor argument present with a null value is 
indistinguishable from an
+        // absent one here, so it resolves to the same default rather than 
failing.
+        TestChatModel model =
+                new TestChatModel(
+                        new ResourceDescriptor(
+                                TestChatModel.class.getName(),
+                                
Collections.singletonMap("structured_output_strategy", null)),
+                        null);
+
+        assertEquals(StructuredOutputStrategy.AUTO, 
model.getStructuredOutputStrategy());
+    }
+
+    @Test
+    @DisplayName("Structured-output strategy is read from the descriptor 
argument")
+    void testStructuredOutputStrategyReadFromDescriptor() {
+        TestChatModel model =
+                new TestChatModel(
+                        new ResourceDescriptor(
+                                TestChatModel.class.getName(),
+                                Map.of("structured_output_strategy", 
"native")),
+                        null);
+
+        assertEquals(StructuredOutputStrategy.NATIVE, 
model.getStructuredOutputStrategy());
+    }
+
+    @Test
+    @DisplayName("An unrecognized structured-output strategy is rejected 
instead of defaulting")
+    void testUnknownStructuredOutputStrategyRejected() {
+        ResourceDescriptor descriptor =
+                new ResourceDescriptor(
+                        TestChatModel.class.getName(),
+                        Map.of("structured_output_strategy", "bogus"));
+
+        assertThrows(IllegalArgumentException.class, () -> new 
TestChatModel(descriptor, null));
+    }
+
+    @Test
+    @DisplayName("AUTO resolves to native only when the effective model is 
capable")
+    void testAutoStrategyResolvesToNativeOnlyWhenCapable() {
+        assertTrue(StructuredOutputStrategy.AUTO.resolvesToNative(true));
+        assertFalse(StructuredOutputStrategy.AUTO.resolvesToNative(false));
+    }
+
+    @Test
+    @DisplayName("NATIVE forces native even when the model is not capable")
+    void testNativeStrategyForcesNativeRegardlessOfCapability() {
+        assertTrue(StructuredOutputStrategy.NATIVE.resolvesToNative(false));
+    }
+
+    @Test
+    @DisplayName("PROMPT never resolves to native even when the model is 
capable")
+    void testPromptStrategyNeverResolvesToNative() {
+        assertFalse(StructuredOutputStrategy.PROMPT.resolvesToNative(true));
+    }
+
     @Test
     @DisplayName("Test chat with long input")
     void testChatWithLongInput() {
diff --git a/python/flink_agents/api/chat_models/chat_model.py 
b/python/flink_agents/api/chat_models/chat_model.py
index ac4a814a..d40f238e 100644
--- a/python/flink_agents/api/chat_models/chat_model.py
+++ b/python/flink_agents/api/chat_models/chat_model.py
@@ -17,11 +17,13 @@
 
#################################################################################
 import re
 from abc import ABC, abstractmethod
+from enum import Enum
 from typing import Any, ClassVar, Dict, List, Mapping, Sequence, Tuple, cast
 
-from pydantic import Field, PrivateAttr
+from pydantic import Field, PrivateAttr, field_validator
 from typing_extensions import override
 
+from flink_agents.api.agents.types import OutputSchema
 from flink_agents.api.chat_message import (
     ChatMessage,
     MessageRole,
@@ -33,6 +35,70 @@ from flink_agents.api.skills import BASH_TOOL, 
LOAD_SKILL_TOOL
 from flink_agents.api.tools.tool import Tool
 
 
+class StructuredOutputStrategy(str, Enum):
+    """User intent about how an output schema should be applied to a chat 
request.
+
+    This expresses *policy* only. Whether a connection *can* apply the 
provider's
+    native structured-output API is a separate, model-dependent *capability*
+    question. Policy and capability are combined at request-build time.
+
+    Inherits from ``str`` so the value survives the JSON-carried bridge to 
Java.
+    Java serializes this enum as its *name* ("NATIVE") while the value here is
+    lowercase, so ``_missing_`` accepts either form in any case — matching the
+    case-insensitive resolver on the Java side.
+
+    Attributes:
+    ----------
+    AUTO : str
+        Use the provider's native structured-output API when the effective 
model is
+        capable of it, and fall back to prompt engineering otherwise. The 
default.
+    NATIVE : str
+        Always use the provider's native structured-output API, without 
consulting
+        the capability predicate.
+    PROMPT : str
+        Never use the provider's native structured-output API; rely on prompt
+        engineering alone. Matches the behavior of connections that have no 
native
+        translation.
+    """
+
+    AUTO = "auto"
+    NATIVE = "native"
+    PROMPT = "prompt"
+
+    def resolves_to_native(self, model_capable: bool) -> bool:  # noqa: FBT001
+        """Resolve this policy against a model's capability into whether to go 
native.
+
+        ``AUTO`` defers to ``model_capable`` (native when the effective model 
can, else
+        the prompt-engineering fallback); ``NATIVE`` always resolves to 
native, ignoring
+        ``model_capable``, so an explicit user intent surfaces a provider 
error rather
+        than silently degrading; ``PROMPT`` never resolves to native.
+
+        Parameters
+        ----------
+        model_capable : bool
+            Whether the connection reports the effective model as natively 
capable.
+
+        Returns:
+        -------
+        bool
+            ``True`` if native structured output should be applied.
+        """
+        if self is StructuredOutputStrategy.NATIVE:
+            return True
+        if self is StructuredOutputStrategy.PROMPT:
+            return False
+        return model_capable
+
+    @classmethod
+    def _missing_(cls, value: object) -> "StructuredOutputStrategy | None":
+        if isinstance(value, str):
+            normalized = value.lower()
+            for member in cls:
+                if normalized in (member.value, member.name.lower()):
+                    return member
+        return None
+
+
 class BaseChatModelConnection(Resource, ABC):
     """Base abstract class for chat model connection.
 
@@ -54,6 +120,61 @@ class BaseChatModelConnection(Resource, ABC):
         """Return resource type of class."""
         return ResourceType.CHAT_MODEL_CONNECTION
 
+    def supports_native_structured_output(self, effective_model: str | None) 
-> bool:
+        """Whether this connection can natively structure output for a given 
model.
+
+        Capability is *model-dependent*, not connection-wide: a single provider
+        connection commonly serves both models that accept a native schema 
parameter and
+        models that do not, so it is evaluated against the *effective* model at
+        request-build time — the model actually being called, which per-request
+        parameters may override.
+
+        The default ``False`` keeps a connection on the prompt-engineering 
fallback. A
+        connection that translates a schema into a native provider parameter 
overrides
+        this; an unrecognized model must report ``False`` so it degrades to 
the fallback
+        rather than failing at the provider.
+
+        Parameters
+        ----------
+        effective_model : str | None
+            The model the request will be issued against, may be ``None``.
+
+        Returns:
+        -------
+        bool
+            ``True`` if a schema can be applied natively for 
``effective_model``.
+        """
+        return False
+
+    def _reject_unsupported_output_schema(
+        self, output_schema: OutputSchema | None
+    ) -> None:
+        """Refuse an output schema this connection cannot translate natively.
+
+        ``chat`` is abstract here, so there is no inherited body that could 
absorb a
+        schema loudly. A connection without a native structured-output 
translation
+        calls this as the first statement of its ``chat`` instead, which turns 
a
+        schema it could only drop into an error rather than an unconstrained 
response
+        that the caller would mistake for a schema-conforming one.
+
+        Args:
+            output_schema: The schema the response should conform to. ``None`` 
returns
+                without effect.
+
+        Raises:
+            NotImplementedError: If ``output_schema`` is not ``None``.
+        """
+        if output_schema is None:
+            return
+        cls = type(self)
+        msg = (
+            f"{cls.__module__}.{cls.__qualname__} has no native 
structured-output"
+            " translation, so it cannot honor the given output schema. 
Override chat()"
+            " to translate the schema natively, or pass no schema so the 
caller applies"
+            " the prompt-engineering fallback."
+        )
+        raise NotImplementedError(msg)
+
     DEFAULT_REASONING_PATTERNS: ClassVar[Tuple[re.Pattern[str], ...]] = (
         re.compile(r"<think>(.*?)</think>", re.DOTALL | re.IGNORECASE),
         re.compile(r"<analysis>(.*?)</analysis>", re.DOTALL | re.IGNORECASE),
@@ -106,6 +227,7 @@ class BaseChatModelConnection(Resource, ABC):
         self,
         messages: Sequence[ChatMessage],
         tools: List[Tool] | None = None,
+        output_schema: OutputSchema | None = None,
         **kwargs: Any,
     ) -> ChatMessage:
         """Direct communication with model service for chat conversation.
@@ -116,6 +238,15 @@ class BaseChatModelConnection(Resource, ABC):
             Input message sequence
         tools : Optional[List]
             List of tools that can be called by the model
+        output_schema : OutputSchema | None
+            The schema the response should conform to, or ``None`` for an
+            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``.
         **kwargs : Any
             Additional parameters passed to the model service (e.g., 
temperature,
             max_tokens, etc.)
@@ -153,6 +284,30 @@ class BaseChatModelSetup(Resource):
     skill_discovery_prompt: str | None = None
     allowed_commands: List[str] = Field(default_factory=list)
     allowed_script_dirs: List[str] = Field(default_factory=list)
+    structured_output_strategy: StructuredOutputStrategy = Field(
+        default=StructuredOutputStrategy.AUTO,
+        description=(
+            "Intent about how an output schema should be applied. Whether 
native "
+            "structured output is actually used combines this policy with the "
+            "connection's model-dependent capability. An explicitly null value 
is "
+            "normalized to AUTO, so a validated setup always carries a real 
strategy."
+        ),
+    )
+
+    @field_validator("structured_output_strategy", mode="before")
+    @classmethod
+    def _normalize_null_strategy(cls, value: Any) -> Any:
+        """Normalize an explicitly null strategy to the ``AUTO`` default.
+
+        A configuration source can carry the key with a null value instead of
+        omitting it. Java cannot tell those two apart — its descriptor argument
+        lookup returns null in both cases and resolves them to ``AUTO`` — so an
+        explicit null resolves to ``AUTO`` here too rather than being rejected.
+        Unknown non-null values still fail validation.
+        """
+        if value is None:
+            return StructuredOutputStrategy.AUTO
+        return value
 
     @property
     @abstractmethod
@@ -256,9 +411,8 @@ class BaseChatModelSetup(Resource):
         # Call chat model connection to execute chat
         merged_kwargs = self.model_kwargs.copy()
         merged_kwargs.update(kwargs)
-        return self._get_connection().chat(
-            messages, tools=self._get_tools(), **merged_kwargs
-        )
+        connection = self._get_connection()
+        return connection.chat(messages, tools=self._get_tools(), 
**merged_kwargs)
 
     def _record_token_metrics(
         self, model_name: str, prompt_tokens: int, completion_tokens: int
diff --git a/python/flink_agents/api/chat_models/tests/test_chat_model_base.py 
b/python/flink_agents/api/chat_models/tests/test_chat_model_base.py
index 92166236..462674bd 100644
--- a/python/flink_agents/api/chat_models/tests/test_chat_model_base.py
+++ b/python/flink_agents/api/chat_models/tests/test_chat_model_base.py
@@ -18,12 +18,14 @@
 from typing import Any, Dict, List, Sequence
 
 import pytest
-from pydantic import Field, ValidationError
+from pydantic import BaseModel, Field, ValidationError
 
+from flink_agents.api.agents.types import OutputSchema
 from flink_agents.api.chat_message import ChatMessage, MessageRole
 from flink_agents.api.chat_models.chat_model import (
     BaseChatModelConnection,
     BaseChatModelSetup,
+    StructuredOutputStrategy,
 )
 from flink_agents.api.prompts.prompt import Prompt
 from flink_agents.api.tools.tool import Tool
@@ -41,18 +43,29 @@ class _MinimalChatModelSetup(BaseChatModelSetup):
         return {"model": self.model}
 
 
+class _Answer(BaseModel):
+    """A representative BaseModel output schema."""
+
+    text: str
+
+
 class _RecordingConnection(BaseChatModelConnection):
-    """Connection that captures the messages it receives for inspection."""
+    """Connection that captures the messages and kwargs it receives for 
inspection."""
 
     captured_messages: List[ChatMessage] = Field(default_factory=list)
+    captured_kwargs: Dict[str, Any] = Field(default_factory=dict)
+    captured_output_schema: OutputSchema | None = None
 
     def chat(
         self,
         messages: Sequence[ChatMessage],
         tools: List[Tool] | None = None,
+        output_schema: OutputSchema | None = None,
         **kwargs: Any,
     ) -> ChatMessage:
         self.captured_messages = list(messages)
+        self.captured_kwargs = dict(kwargs)
+        self.captured_output_schema = output_schema
         return ChatMessage(role=MessageRole.ASSISTANT, content="ok")
 
 
@@ -125,3 +138,120 @@ def 
test_chat_refills_template_on_subsequent_invocations() -> None:
     assert len(connection.captured_messages) == 2
     assert connection.captured_messages[0].content == "Task: v1"
     assert connection.captured_messages[1].content == "tool result"
+
+
+def test_default_capability_predicate_is_false() -> None:
+    """A connection reports no native structured output for any model by 
default."""
+    connection = _RecordingConnection()
+
+    assert connection.supports_native_structured_output("gpt-4o") is False
+    assert connection.supports_native_structured_output("gpt-3.5-turbo") is 
False
+    assert connection.supports_native_structured_output(None) is False
+
+
+def test_output_schema_guard_rejects_a_schema() -> None:
+    """The guard refuses a schema a connection cannot translate natively.
+
+    Dropping it instead would return an unconstrained response that the caller 
has no
+    way to tell apart from a schema-conforming one.
+    """
+    connection = _RecordingConnection()
+    schema = OutputSchema(output_schema=_Answer)
+
+    with pytest.raises(NotImplementedError, match="_RecordingConnection"):
+        connection._reject_unsupported_output_schema(schema)
+
+
+def test_output_schema_guard_passes_through_none() -> None:
+    """A caller on the prompt-engineering fallback passes None and is let 
through."""
+    connection = _RecordingConnection()
+
+    assert connection._reject_unsupported_output_schema(None) is None
+
+
+def test_setup_routes_output_schema_through_to_connection() -> None:
+    """chat() forwards a caller's ``output_schema`` on to the connection 
intact.
+
+    The setup filters what reaches the connection, so a schema it dropped or 
consumed
+    would leave the connection unable to apply one at all. That the schema 
cannot land
+    in ``**kwargs`` is a separate, tree-wide invariant covered by the 
connection
+    signature guard.
+    """
+    setup = _RecordingChatModelSetup(connection="c", model="m")
+    connection = _RecordingConnection()
+    setup._resolved_connection = connection
+
+    schema = OutputSchema(output_schema=_Answer)
+    setup.chat([], output_schema=schema)
+
+    assert connection.captured_output_schema is schema
+
+
+def test_structured_output_strategy_defaults_to_auto() -> None:
+    """The setup policy defaults to AUTO when unset."""
+    setup = _RecordingChatModelSetup(connection="c", model="m")
+
+    assert setup.structured_output_strategy is StructuredOutputStrategy.AUTO
+
+
[email protected]("raw", ["NATIVE", "native", "Native"])
+def test_structured_output_strategy_coerces_name_and_value_case_insensitively(
+    raw: str,
+) -> None:
+    """The policy coerces from either its name or its value, in any case.
+
+    Java serializes this enum as its name ("NATIVE") and its own resolver 
accepts any
+    case, so a Python side that only accepted the lowercase value would reject 
what
+    Java sends.
+    """
+    setup = _RecordingChatModelSetup(
+        connection="c", model="m", structured_output_strategy=raw
+    )
+
+    assert setup.structured_output_strategy is StructuredOutputStrategy.NATIVE
+
+
+def test_structured_output_strategy_normalizes_explicit_none_to_auto() -> None:
+    """An explicitly null policy resolves to AUTO instead of being rejected.
+
+    Java cannot distinguish a configuration that carries the key as null from 
one
+    that omits it, and resolves both to AUTO, so a null arriving on Python 
must not
+    fail validation or leave the attribute None.
+    """
+    setup = _RecordingChatModelSetup(
+        connection="c", model="m", structured_output_strategy=None
+    )
+
+    assert setup.structured_output_strategy is StructuredOutputStrategy.AUTO
+
+
[email protected]("raw", ["bogus", ""])
+def test_structured_output_strategy_rejects_unrecognized_value(raw: str) -> 
None:
+    """Only null normalizes to AUTO; every other unrecognized value still 
raises.
+
+    An empty string reaches this field in practice from an empty YAML scalar 
or an
+    unset environment substitution, and it must not be mistaken for an omitted 
value:
+    normalizing on falsiness rather than on null would accept it as AUTO. A 
non-empty
+    unrecognized name survives that same falsiness check, so it takes 
`"bogus"` to
+    catch a resolver that coerces any unknown string to AUTO.
+    """
+    with pytest.raises(ValidationError):
+        _RecordingChatModelSetup(
+            connection="c", model="m", structured_output_strategy=raw
+        )
+
+
+def test_auto_strategy_resolves_to_native_only_when_capable() -> None:
+    """AUTO defers to the model's capability."""
+    assert StructuredOutputStrategy.AUTO.resolves_to_native(True) is True
+    assert StructuredOutputStrategy.AUTO.resolves_to_native(False) is False
+
+
+def test_native_strategy_forces_native_regardless_of_capability() -> None:
+    """NATIVE resolves to native even when the model is not capable."""
+    assert StructuredOutputStrategy.NATIVE.resolves_to_native(False) is True
+
+
+def test_prompt_strategy_never_resolves_to_native() -> None:
+    """PROMPT never resolves to native even when the model is capable."""
+    assert StructuredOutputStrategy.PROMPT.resolves_to_native(True) is False
diff --git 
a/python/flink_agents/e2e_tests/e2e_tests_integration/mock_chat_model_agent.py 
b/python/flink_agents/e2e_tests/e2e_tests_integration/mock_chat_model_agent.py
index 625bb69f..2aa03c26 100644
--- 
a/python/flink_agents/e2e_tests/e2e_tests_integration/mock_chat_model_agent.py
+++ 
b/python/flink_agents/e2e_tests/e2e_tests_integration/mock_chat_model_agent.py
@@ -29,6 +29,7 @@ from pydantic import BaseModel
 from pyflink.datastream import KeySelector
 
 from flink_agents.api.agents.agent import Agent
+from flink_agents.api.agents.types import OutputSchema
 from flink_agents.api.chat_message import ChatMessage, MessageRole
 from flink_agents.api.chat_models.chat_model import (
     BaseChatModelConnection,
@@ -95,9 +96,16 @@ class MockChatModelConnection(BaseChatModelConnection):
         self,
         messages: Sequence[ChatMessage],
         tools: List | None = None,
+        output_schema: OutputSchema | None = None,
         **kwargs: Any,
     ) -> ChatMessage:
-        """Generate a tool call or a response according to input."""
+        """Generate a tool call or a response according to input.
+
+        A non-``None`` ``output_schema`` is rejected: this connection has no 
native
+        structured-output translation. Declaring the parameter keeps a 
caller-supplied
+        schema out of ``**kwargs``.
+        """
+        self._reject_unsupported_output_schema(output_schema)
         if "sum" in messages[-1].content:
             input = messages[-1].content
             # Validate the tool was bound before the model was invoked.
diff --git 
a/python/flink_agents/e2e_tests/e2e_tests_integration/tool_parameter_injection_agent.py
 
b/python/flink_agents/e2e_tests/e2e_tests_integration/tool_parameter_injection_agent.py
index 0b30f9af..9358fec9 100644
--- 
a/python/flink_agents/e2e_tests/e2e_tests_integration/tool_parameter_injection_agent.py
+++ 
b/python/flink_agents/e2e_tests/e2e_tests_integration/tool_parameter_injection_agent.py
@@ -18,6 +18,7 @@
 from pyflink.datastream import KeySelector
 
 from flink_agents.api.agents.agent import Agent
+from flink_agents.api.agents.types import OutputSchema
 from flink_agents.api.chat_message import ChatMessage, MessageRole
 from flink_agents.api.chat_models.chat_model import (
     BaseChatModelConnection,
@@ -113,9 +114,16 @@ class MockToolChatConnection(BaseChatModelConnection):
         self,
         messages: list[ChatMessage],
         tools: list[BaseTool] | None = None,
+        output_schema: OutputSchema | None = None,
         **kwargs: object,
     ) -> ChatMessage:
-        """Return a tool call for user input, or echo the tool response."""
+        """Return a tool call for user input, or echo the tool response.
+
+        A non-``None`` ``output_schema`` is rejected: this connection has no 
native
+        structured-output translation. Declaring the parameter keeps a 
caller-supplied
+        schema out of ``**kwargs``.
+        """
+        self._reject_unsupported_output_schema(output_schema)
         last_message = messages[-1]
         if last_message.role == MessageRole.TOOL:
             return ChatMessage(role=MessageRole.ASSISTANT, 
content=last_message.content)
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 af5483dc..afcd3f12 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,6 +24,7 @@ from anthropic.types import MessageParam, TextBlockParam, 
ToolParam
 from pydantic import Field, PrivateAttr
 from typing_extensions import override
 
+from flink_agents.api.agents.types import OutputSchema
 from flink_agents.api.chat_message import ChatMessage, MessageRole
 from flink_agents.api.chat_models.chat_model import (
     BaseChatModelConnection,
@@ -168,9 +169,17 @@ class 
AnthropicChatModelConnection(BaseChatModelConnection):
         self,
         messages: Sequence[ChatMessage],
         tools: List[Tool] | None = None,
+        output_schema: OutputSchema | None = None,
         **kwargs: Any,
     ) -> ChatMessage:
-        """Direct communication with Anthropic model service for chat 
conversation."""
+        """Direct communication with Anthropic model service for chat 
conversation.
+
+        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.
+        """
+        self._reject_unsupported_output_schema(output_schema)
         anthropic_tools = None
         if tools is not None:
             anthropic_tools = [
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 18a09212..b653b4d6 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
@@ -21,6 +21,7 @@ from typing import Any, Dict, List, Sequence
 from openai import NOT_GIVEN, AzureOpenAI
 from pydantic import Field, PrivateAttr
 
+from flink_agents.api.agents.types import OutputSchema
 from flink_agents.api.chat_message import ChatMessage
 from flink_agents.api.chat_models.chat_model import (
     BaseChatModelConnection,
@@ -117,6 +118,7 @@ class 
AzureOpenAIChatModelConnection(BaseChatModelConnection):
         self,
         messages: Sequence[ChatMessage],
         tools: List[Tool] | None = None,
+        output_schema: OutputSchema | None = None,
         **kwargs: Any,
     ) -> ChatMessage:
         """Direct communication with model service for chat conversation.
@@ -127,6 +129,11 @@ class 
AzureOpenAIChatModelConnection(BaseChatModelConnection):
             Input message sequence
         tools : Optional[List]
             List of tools that can be called by the model
+        output_schema : OutputSchema | None
+            Rejected when non-``None``: 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.
         **kwargs : Any
             Additional parameters passed to the model service (e.g., 
temperature,
             max_tokens, etc.)
@@ -136,6 +143,7 @@ class 
AzureOpenAIChatModelConnection(BaseChatModelConnection):
         ChatMessage
             Model response message
         """
+        self._reject_unsupported_output_schema(output_schema)
         tool_specs = None
         if tools is not None:
             tool_specs = [to_openai_tool(metadata=tool.metadata) for tool in 
tools]
diff --git a/python/flink_agents/integrations/chat_models/ollama_chat_model.py 
b/python/flink_agents/integrations/chat_models/ollama_chat_model.py
index 7c36ec38..b0061fb8 100644
--- a/python/flink_agents/integrations/chat_models/ollama_chat_model.py
+++ b/python/flink_agents/integrations/chat_models/ollama_chat_model.py
@@ -21,6 +21,7 @@ from typing import Any, Dict, List, Literal, Sequence
 from ollama import Client, Message
 from pydantic import Field
 
+from flink_agents.api.agents.types import OutputSchema
 from flink_agents.api.chat_message import ChatMessage, MessageRole
 from flink_agents.api.chat_models.chat_model import (
     BaseChatModelConnection,
@@ -85,9 +86,17 @@ class OllamaChatModelConnection(BaseChatModelConnection):
         self,
         messages: Sequence[ChatMessage],
         tools: List[Tool] | None = None,
+        output_schema: OutputSchema | None = None,
         **kwargs: Any,
     ) -> ChatMessage:
-        """Process a sequence of messages, and return a response."""
+        """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.
+        """
+        self._reject_unsupported_output_schema(output_schema)
         ollama_messages = self.__convert_to_ollama_messages(messages)
 
         # Convert tool format
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 0d487772..68c2414b 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
@@ -22,6 +22,7 @@ from openai import NOT_GIVEN, OpenAI
 from pydantic import Field, PrivateAttr
 from typing_extensions import override
 
+from flink_agents.api.agents.types import OutputSchema
 from flink_agents.api.chat_message import ChatMessage
 from flink_agents.api.chat_models.chat_model import (
     BaseChatModelConnection,
@@ -138,6 +139,7 @@ class OpenAIChatModelConnection(BaseChatModelConnection):
         self,
         messages: Sequence[ChatMessage],
         tools: List[Tool] | None = None,
+        output_schema: OutputSchema | None = None,
         **kwargs: Any,
     ) -> ChatMessage:
         """Direct communication with model service for chat conversation.
@@ -148,6 +150,11 @@ class OpenAIChatModelConnection(BaseChatModelConnection):
             Input message sequence
         tools : Optional[List]
             List of tools that can be called by the model
+        output_schema : OutputSchema | None
+            Rejected when non-``None``: 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.
         **kwargs : Any
             Additional parameters passed to the model service (e.g., 
temperature,
             max_tokens, etc.)
@@ -157,6 +164,7 @@ class OpenAIChatModelConnection(BaseChatModelConnection):
         ChatMessage
             Model response message
         """
+        self._reject_unsupported_output_schema(output_schema)
         tool_specs = None
         if tools is not None:
             tool_specs = [to_openai_tool(metadata=tool.metadata) for tool in 
tools]
diff --git 
a/python/flink_agents/integrations/chat_models/tests/test_output_schema_param_declared.py
 
b/python/flink_agents/integrations/chat_models/tests/test_output_schema_param_declared.py
new file mode 100644
index 00000000..e31d3753
--- /dev/null
+++ 
b/python/flink_agents/integrations/chat_models/tests/test_output_schema_param_declared.py
@@ -0,0 +1,168 @@
+################################################################################
+#  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.
+#################################################################################
+"""Guards the ``output_schema`` parameter contract across every chat 
connection."""
+
+import inspect
+from typing import Iterator, List, Type
+
+from pydantic import BaseModel
+
+from flink_agents.api.agents.types import OutputSchema
+from flink_agents.api.chat_models import java_chat_model as api_java_chat_model
+from flink_agents.api.chat_models.chat_model import BaseChatModelConnection
+from flink_agents.e2e_tests.e2e_tests_integration import (
+    mock_chat_model_agent,
+    tool_parameter_injection_agent,
+)
+from flink_agents.integrations.chat_models import ollama_chat_model, 
tongyi_chat_model
+from flink_agents.integrations.chat_models.anthropic import 
anthropic_chat_model
+from flink_agents.integrations.chat_models.azure import azure_openai_chat_model
+from flink_agents.integrations.chat_models.openai import openai_chat_model
+from flink_agents.runtime.java import java_chat_model as 
runtime_java_chat_model
+
+# A class is only discoverable through __subclasses__() once it has been 
imported.
+# Importing every module that defines a connection is what gives the walk 
below its
+# reach — including the cross-language bridge and the e2e test doubles.
+_MODULES_DEFINING_CONNECTIONS = (
+    anthropic_chat_model,
+    api_java_chat_model,
+    azure_openai_chat_model,
+    mock_chat_model_agent,
+    ollama_chat_model,
+    openai_chat_model,
+    runtime_java_chat_model,
+    tongyi_chat_model,
+    tool_parameter_injection_agent,
+)
+
+
+class _Answer(BaseModel):
+    """A representative output schema to hand to a connection."""
+
+    text: str
+
+
+def _subclasses_recursive(cls: Type[object]) -> Iterator[Type[object]]:
+    for subclass in cls.__subclasses__():
+        yield subclass
+        yield from _subclasses_recursive(subclass)
+
+
+def _is_test_local(cls: Type[object]) -> bool:
+    """Whether ``cls`` is defined inside a ``tests`` package.
+
+    A whole-tree pytest run imports every test module into one process, so 
doubles
+    declared inside a test file also turn up in the walk. Such a double may
+    deliberately accept a schema in order to assert on what a caller routed to 
it,
+    which is the opposite of the contract asserted here.
+    """
+    return "tests" in cls.__module__.split(".")
+
+
+def _connections_implementing_chat() -> List[Type[BaseChatModelConnection]]:
+    """Connections outside a ``tests`` package that supply their own ``chat`` 
body.
+
+    A class that never overrides ``chat`` inherits only the abstract 
declaration, so
+    there is no body to assert on.
+    """
+    return [
+        cls
+        for cls in _subclasses_recursive(BaseChatModelConnection)
+        if not _is_test_local(cls) and cls.chat is not 
BaseChatModelConnection.chat
+    ]
+
+
+def test_every_connection_declares_output_schema_param() -> None:
+    """Every connection in the tree must declare ``output_schema`` defaulting 
to None.
+
+    A connection that omits the parameter absorbs a caller's ``output_schema`` 
into
+    ``**kwargs``. Connections forward ``**kwargs`` to their provider SDK — and 
the
+    cross-language bridge forwards it to Java as ``modelParams`` — so the 
schema would
+    reach the request body as an unknown field. Declaring the parameter is 
what makes
+    the leak impossible rather than merely unlikely.
+
+    The tree is walked rather than hand-listed: a hand-kept list silently stops
+    guarding whatever it was never updated to mention, including the abstract 
base
+    itself.
+    """
+    offenders: List[str] = []
+    connections = [
+        BaseChatModelConnection,
+        *_subclasses_recursive(BaseChatModelConnection),
+    ]
+    for cls in connections:
+        param = inspect.signature(cls.chat).parameters.get("output_schema")
+        if param is None:
+            offenders.append(f"{cls.__module__}.{cls.__qualname__}: parameter 
missing")
+        elif param.default is not None:
+            offenders.append(
+                f"{cls.__module__}.{cls.__qualname__}: default is "
+                f"{param.default!r}, expected None"
+            )
+
+    assert not offenders, (
+        "connections violating the output_schema contract:\n" + 
"\n".join(offenders)
+    )
+
+
+def _schema_rejection_failure(
+    cls: Type[BaseChatModelConnection], schema: OutputSchema
+) -> str | None:
+    """Describe how ``cls.chat`` mishandles ``schema``, or ``None`` if it 
rejects it.
+
+    ``__new__`` skips ``__init__``, so no credentials, no client and no 
network are
+    involved: the rejection has to happen before the first attribute access 
for the
+    call to get this far, which is what pins the guard to the top of ``chat``.
+    """
+    name = f"{cls.__module__}.{cls.__qualname__}"
+    connection = cls.__new__(cls)
+    try:
+        cls.chat(connection, messages=[], tools=None, output_schema=schema)
+    except NotImplementedError:
+        return None
+    except Exception as exc:
+        return f"{name}: raised {type(exc).__name__} before rejecting the 
schema"
+    return f"{name}: accepted the schema instead of rejecting it"
+
+
+def test_every_connection_rejects_an_output_schema_it_cannot_translate() -> 
None:
+    """A connection with no native translation must reject a schema, not drop 
it.
+
+    Declaring the parameter only keeps the schema out of the provider request; 
on its
+    own it lets a connection silently return an unconstrained response that 
the caller
+    would treat as schema-conforming. Rejecting turns that into an error at 
the call.
+
+    The tree is walked rather than hand-listed, so a connection added to a 
module that
+    is already imported is held to the contract for free. Reach still stops at 
those
+    imports: ``__subclasses__()`` only sees classes that have been imported, 
so a
+    connection in a brand-new module needs that module added at the top of 
this file.
+    """
+    schema = OutputSchema(output_schema=_Answer)
+    connections = _connections_implementing_chat()
+
+    assert connections, "the connection walk found nothing to check"
+    offenders = [
+        failure
+        for cls in connections
+        if (failure := _schema_rejection_failure(cls, schema)) is not None
+    ]
+
+    assert not offenders, (
+        "connections that do not reject an untranslatable output_schema:\n"
+        + "\n".join(offenders)
+    )
diff --git a/python/flink_agents/integrations/chat_models/tongyi_chat_model.py 
b/python/flink_agents/integrations/chat_models/tongyi_chat_model.py
index 6587a8cb..5e7b52a1 100644
--- a/python/flink_agents/integrations/chat_models/tongyi_chat_model.py
+++ b/python/flink_agents/integrations/chat_models/tongyi_chat_model.py
@@ -24,6 +24,7 @@ from typing import Any, Dict, List, Sequence, cast
 from dashscope import Generation
 from pydantic import Field
 
+from flink_agents.api.agents.types import OutputSchema
 from flink_agents.api.chat_message import ChatMessage, MessageRole
 from flink_agents.api.chat_models.chat_model import (
     BaseChatModelConnection,
@@ -101,9 +102,17 @@ class TongyiChatModelConnection(BaseChatModelConnection):
         self,
         messages: Sequence[ChatMessage],
         tools: List[Tool] | None = None,
+        output_schema: OutputSchema | None = None,
         **kwargs: Any,
     ) -> ChatMessage:
-        """Process a sequence of messages, and return a response."""
+        """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.
+        """
+        self._reject_unsupported_output_schema(output_schema)
         tongyi_messages = self.__convert_to_tongyi_messages(messages)
 
         tongyi_tools: List[Dict[str, Any]] | None = (
diff --git a/python/flink_agents/runtime/java/java_chat_model.py 
b/python/flink_agents/runtime/java/java_chat_model.py
index 3f385a9a..15df3157 100644
--- a/python/flink_agents/runtime/java/java_chat_model.py
+++ b/python/flink_agents/runtime/java/java_chat_model.py
@@ -19,6 +19,7 @@ from typing import Any, Dict, List, Mapping, Sequence
 
 from typing_extensions import override
 
+from flink_agents.api.agents.types import OutputSchema
 from flink_agents.api.chat_message import ChatMessage
 from flink_agents.api.chat_models.java_chat_model import (
     JavaChatModelConnection,
@@ -64,14 +65,22 @@ class JavaChatModelConnectionImpl(JavaChatModelConnection):
         self,
         messages: Sequence[ChatMessage],
         tools: List[Tool] | None = None,
+        output_schema: OutputSchema | None = None,
         **kwargs: Any,
     ) -> ChatMessage:
-        """Chat method that throws UnsupportedOperationException.
+        """Chat by forwarding the request to the wrapped Java connection.
 
         This connection serves as a Java resource wrapper only.
         Chat operations should be performed on the Java side using the 
underlying Java
         chat model object.
+
+        A non-``None`` ``output_schema`` is rejected: only messages, tools and 
kwargs
+        cross to the Java three-argument ``chat``, so a schema forwarded here 
would be
+        dropped on the way. Declaring the parameter keeps a caller-supplied 
schema out
+        of ``**kwargs``, which crosses to Java as ``modelParams`` — a 
provider-facing
+        map, not a channel for framework execution metadata.
         """
+        self._reject_unsupported_output_schema(output_schema)
         java_messages = [
             self._j_resource_adapter.fromPythonChatMessage(message)
             for message in messages

Reply via email to