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 25b090499 [integrations][python] Apply DashScope native structured 
output (#1090)
25b090499 is described below

commit 25b090499ee4ba9cb2d53025aafb89125feeaa59
Author: Weiqing Yang <[email protected]>
AuthorDate: Mon Sep 14 04:26:53 2026 -0700

    [integrations][python] Apply DashScope native structured output (#1090)
    
    Generated-by: Claude Code 2.1.270 (Claude Opus 5)
---
 docs/content/docs/development/chat_models.md       |   4 +-
 .../tests/test_tongyi_native_structured_output.py  | 262 +++++++++++++++++++++
 .../integrations/chat_models/tongyi_chat_model.py  | 150 +++++++++++-
 3 files changed, 407 insertions(+), 9 deletions(-)

diff --git a/docs/content/docs/development/chat_models.md 
b/docs/content/docs/development/chat_models.md
index 588bff641..3bf94a5d1 100644
--- a/docs/content/docs/development/chat_models.md
+++ b/docs/content/docs/development/chat_models.md
@@ -1077,12 +1077,12 @@ Tongyi is only supported in Python currently. To use 
Tongyi from Java agents, se
 | Parameter | Type | Default | Description |
 |-----------|------|---------|-------------|
 | `connection` | str | Required | Reference to connection method name |
-| `model` | str | `"qwen-plus"` | Name of the chat model to use |
+| `model` | str | `"qwen-plus"` | Name of the chat model to use (an 
`output_schema` is enforced natively only on `qwen3.7-max`, 
`qwen3.7-max-preview`, `qwen3.7-max-2026-05-17` and `qwen3.7-max-2026-05-20`, 
the schema-capable models DashScope serves on the text-generation interface 
this connection calls; every other model, the `qwen-plus` default included, 
falls back to prompting) |
 | `prompt` | Prompt \| str | None | Prompt template or reference to prompt 
resource |
 | `tools` | List[str] | None | List of tool names available to the model |
 | `temperature` | float | `0.7` | Sampling temperature (0.0 to 2.0) |
 | `extract_reasoning` | bool | `False` | Extract reasoning content from 
response |
-| `additional_kwargs` | dict | `{}` | Additional DashScope API parameters |
+| `additional_kwargs` | dict | `{}` | Additional DashScope API parameters. A 
`response_format` supplied here is rejected only when it would be overwritten, 
that is when an `output_schema` carrying a Pydantic model is set on a model 
that applies it natively; otherwise it is passed through unchanged |
 
 #### Usage Example
 
diff --git 
a/python/flink_agents/integrations/chat_models/tests/test_tongyi_native_structured_output.py
 
b/python/flink_agents/integrations/chat_models/tests/test_tongyi_native_structured_output.py
new file mode 100644
index 000000000..c249de51b
--- /dev/null
+++ 
b/python/flink_agents/integrations/chat_models/tests/test_tongyi_native_structured_output.py
@@ -0,0 +1,262 @@
+################################################################################
+#  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 types import SimpleNamespace
+from typing import Any, Callable
+from unittest.mock import MagicMock
+
+import pytest
+from pydantic import BaseModel
+from pyflink.common.typeinfo import Types
+
+from flink_agents.api.agents.types import OutputSchema
+from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.integrations.chat_models.tongyi_chat_model import (
+    TongyiChatModelConnection,
+)
+
+# The models DashScope documents native structured output for on the
+# text-generation endpoint this connection calls. The names are written out 
here
+# rather than read from the connection, so that a name mistyped there is a
+# disagreement between two lists rather than a value both sides share.
+_CAPABLE_MODEL = "qwen3.7-max"
+_CAPABLE_MODELS = [
+    "qwen3.7-max",
+    "qwen3.7-max-preview",
+    "qwen3.7-max-2026-05-17",
+    "qwen3.7-max-2026-05-20",
+]
+
+# Names that must not be treated as capable. qwen-plus is the connection's 
default
+# model, qwen3.7-max-2026-06-08 is the member of the capable family served 
only on
+# the multimodal interface, and qwen3.8-max is a schema-capable family 
reachable
+# only through that interface.
+_INCAPABLE_MODELS = [
+    "qwen-plus",
+    "qwen-turbo",
+    "qwen3.8-max",
+    "qwen3.7-max-2026-06-08",
+    "",
+    None,
+]
+
+
+class Person(BaseModel):
+    """A representative flat 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]
+
+
+def _connection() -> TongyiChatModelConnection:
+    return TongyiChatModelConnection(api_key="fake-key")
+
+
+def _messages() -> list[ChatMessage]:
+    return [ChatMessage(role=MessageRole.USER, content="hi")]
+
+
+def _mocked_response() -> SimpleNamespace:
+    """The minimum response shape the connection reads back after the call."""
+    return SimpleNamespace(
+        status_code=200,
+        output={
+            "choices": [
+                {
+                    "message": {
+                        "role": "assistant",
+                        "content": "ok",
+                        "tool_calls": None,
+                    }
+                }
+            ]
+        },
+        usage=SimpleNamespace(input_tokens=1, output_tokens=2),
+    )
+
+
+def _patched_call(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
+    """Stand a mock in for the provider call and hand it back to the caller.
+
+    Returned rather than kept private so a test can assert on whether the 
provider
+    was reached at all, not only on what it received.
+    """
+    mock_call = MagicMock(return_value=_mocked_response())
+    monkeypatch.setattr(
+        
"flink_agents.integrations.chat_models.tongyi_chat_model.Generation.call",
+        mock_call,
+    )
+    return mock_call
+
+
+def _chat(
+    monkeypatch: pytest.MonkeyPatch, **chat_kwargs: Any
+) -> tuple[ChatMessage, dict[str, Any]]:
+    """Drive one chat call against a mocked provider, so no server is 
contacted.
+
+    Returns the response together with the keyword arguments the provider call
+    received, which is the whole request: every argument reaches the provider
+    entry point as a keyword.
+    """
+    mock_call = _patched_call(monkeypatch)
+    response = _connection().chat(_messages(), **chat_kwargs)
+    return response, mock_call.call_args.kwargs
+
+
+def test_native_response_format_applied_on_capable_model(monkeypatch) -> None:
+    """A BaseModel schema reaches a capable model as a json_schema response 
format.
+
+    The document is the renderer's output as produced, so the equality 
assertion
+    also pins that nothing post-processes it. ``result_format`` is asserted
+    alongside because it sits one prefix away from ``response_format`` and the
+    response-parsing path depends on it staying ``message``.
+    """
+    _, kwargs = _chat(
+        monkeypatch,
+        model=_CAPABLE_MODEL,
+        output_schema=OutputSchema(output_schema=Person),
+    )
+    response_format = kwargs["response_format"]
+    assert response_format["type"] == "json_schema"
+    assert response_format["json_schema"]["name"] == "Person"
+    assert response_format["json_schema"]["strict"] is True
+    assert response_format["json_schema"]["schema"] == 
Person.model_json_schema()
+    assert kwargs["result_format"] == "message"
+
+
+def test_native_not_applied_for_default_model(monkeypatch) -> None:
+    """The default model answers a schema instead of refusing it, and sends 
none.
+
+    Omitting ``model`` is the path every existing caller is on, so the schema 
is
+    answered with the prompt-engineering fallback rather than raising, and no
+    undocumented parameter reaches the request.
+    """
+    response, kwargs = _chat(
+        monkeypatch, output_schema=OutputSchema(output_schema=Person)
+    )
+    assert response.content == "ok"
+    assert "response_format" not in kwargs
+
+
+def test_native_not_applied_when_schema_none(monkeypatch) -> None:
+    """A call without a schema carries no response format key at all.
+
+    The key must be absent rather than present with a ``None`` value, which the
+    provider would read as a parameter it was given.
+    """
+    _, kwargs = _chat(monkeypatch, model=_CAPABLE_MODEL, output_schema=None)
+    assert "response_format" not in kwargs
+
+
+def test_native_not_applied_for_row_type_info(monkeypatch) -> None:
+    """A RowTypeInfo schema falls back to prompting rather than raising.
+
+    There is no native translation for it, so the request is left unchanged and
+    no RowTypeInfo reaches the request body.
+    """
+    row_type = Types.ROW_NAMED(["name"], [Types.STRING()])
+    response, kwargs = _chat(
+        monkeypatch,
+        model=_CAPABLE_MODEL,
+        output_schema=OutputSchema(output_schema=row_type),
+    )
+    assert response.content == "ok"
+    assert "response_format" not in kwargs
+
+
+def test_unrenderable_schema_raises_naming_the_model(monkeypatch) -> None:
+    """A schema that cannot be rendered fails here, named, not at the 
provider."""
+    with pytest.raises(TypeError, match="Unrenderable cannot be rendered"):
+        _chat(
+            monkeypatch,
+            model=_CAPABLE_MODEL,
+            output_schema=OutputSchema(output_schema=Unrenderable),
+        )
+
+
[email protected]("model", _CAPABLE_MODELS)
+def test_capability_predicate_accepts_capable_models(model: str) -> None:
+    """Every model documented as schema-capable on the text interface is 
capable."""
+    assert _connection().supports_native_structured_output(model) is True
+
+
[email protected]("model", _INCAPABLE_MODELS)
+def test_capability_predicate_rejects_incapable_models(model: str | None) -> 
None:
+    """Other families, a multimodal-only snapshot, and no model are not 
capable."""
+    assert _connection().supports_native_structured_output(model) is False
+
+
+def test_caller_supplied_response_format_conflicts(monkeypatch) -> None:
+    """A caller's own response format and a schema collide, and the call is 
refused.
+
+    Silently resolving the collision would either drop the caller's parameter 
or
+    drop the schema, and neither is visible from the response. The refusal is
+    asserted to reach the provider never, because raising after the call would
+    still bill the caller for a response nothing reads.
+    """
+    mock_call = _patched_call(monkeypatch)
+    with pytest.raises(ValueError, match="response_format must not also be 
passed"):
+        _connection().chat(
+            _messages(),
+            model=_CAPABLE_MODEL,
+            output_schema=OutputSchema(output_schema=Person),
+            response_format={"type": "json_object"},
+        )
+    mock_call.assert_not_called()
+
+
+def test_unrenderable_schema_conflicts_before_it_is_rendered(monkeypatch) -> 
None:
+    """A caller's response format collides even with a schema that cannot 
render.
+
+    The conflict is settled from the schema class, which is known without 
rendering,
+    so it is reported ahead of a render failure the caller had already steered 
the
+    request away from. Rendering first would report the wrong problem.
+    """
+    mock_call = _patched_call(monkeypatch)
+    with pytest.raises(ValueError, match="response_format must not also be 
passed"):
+        _connection().chat(
+            _messages(),
+            model=_CAPABLE_MODEL,
+            output_schema=OutputSchema(output_schema=Unrenderable),
+            response_format={"type": "json_object"},
+        )
+    mock_call.assert_not_called()
+
+
+def test_row_type_info_leaves_a_caller_response_format_alone(monkeypatch) -> 
None:
+    """A payload with no native translation is no conflict, so the caller wins.
+
+    Nothing is derived from a RowTypeInfo, so there is no second response 
format to
+    collide with the caller's and no reason to refuse the call. Testing the 
conflict
+    before resolving the payload would raise here instead.
+    """
+    caller_format = {"type": "json_object"}
+    row_type = Types.ROW_NAMED(["name"], [Types.STRING()])
+    response, kwargs = _chat(
+        monkeypatch,
+        model=_CAPABLE_MODEL,
+        output_schema=OutputSchema(output_schema=row_type),
+        response_format=caller_format,
+    )
+    assert response.content == "ok"
+    assert kwargs["response_format"] == caller_format
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 5e7b52a1c..0c05af5f0 100644
--- a/python/flink_agents/integrations/chat_models/tongyi_chat_model.py
+++ b/python/flink_agents/integrations/chat_models/tongyi_chat_model.py
@@ -22,9 +22,10 @@ import uuid
 from typing import Any, Dict, List, Sequence, cast
 
 from dashscope import Generation
-from pydantic import Field
+from pydantic import BaseModel, Field
+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,
@@ -35,6 +36,74 @@ from flink_agents.api.tools.tool import Tool, ToolMetadata
 DEFAULT_REQUEST_TIMEOUT = 60.0
 DEFAULT_MODEL = "qwen-plus"
 
+# Models with documented json_schema support that are also served on the
+# text-generation interface this connection calls. json_schema support is 
documented
+# per family, but the interface is documented per model and differs inside a 
family:
+# the Qwen3.7-Plus, Qwen3.7-Flash, Qwen3.8-Flash and Qwen3.8-Max families and 
the
+# qwen3.7-max-2026-06-08 snapshot are served on the multimodal interface and 
answer
+# Generation.call with "url error". Names are therefore matched exactly 
against the
+# Qwen3.7-Max members served on the text interface.
+# json_schema model list and mode semantics:
+#   https://help.aliyun.com/zh/model-studio/json-mode
+# text- vs multimodal-interface routing:
+#   https://help.aliyun.com/zh/model-studio/text-generation
+#
+# A name outside the set reports not-capable and degrades to the 
prompt-engineering
+# fallback rather than failing at the provider.
+_NATIVE_STRUCTURED_OUTPUT_MODELS = frozenset(
+    {
+        "qwen3.7-max",
+        "qwen3.7-max-preview",
+        "qwen3.7-max-2026-05-17",
+        "qwen3.7-max-2026-05-20",
+    }
+)
+
+
+def _native_output_model(
+    output_schema: OutputSchema | None,
+) -> type[BaseModel] | None:
+    """The model a schema translates natively to, or ``None`` where none 
applies.
+
+    ``None`` covers both no schema at all and a ``RowTypeInfo``, which has no 
native
+    translation 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.
+    """
+    schema = getattr(output_schema, "output_schema", None)
+    if not (isinstance(schema, type) and issubclass(schema, BaseModel)):
+        return None
+    return schema
+
+
+def _native_response_format(
+    output_schema: OutputSchema | None,
+) -> Dict[str, Any] | None:
+    """Build the DashScope ``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 Pydantic'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__,
+            "strict": True,
+            "schema": render_output_schema(model, lambda m: 
m.model_json_schema()),
+        },
+    }
+
 
 def to_dashscope_tool(
     metadata: ToolMetadata,
@@ -98,6 +167,23 @@ class TongyiChatModelConnection(BaseChatModelConnection):
             **kwargs,
         )
 
+    @override
+    def supports_native_structured_output(self, effective_model: str | None) 
-> bool:
+        """Whether DashScope documents structured output for 
``effective_model``.
+
+        See the module-level allowlist for the source of truth and for why 
names are
+        matched exactly. A name outside it reports ``False`` so it degrades to 
the
+        prompt-engineering fallback rather than failing at the provider.
+
+        Args:
+            effective_model: The model the request will be issued against, may 
be
+                ``None``.
+
+        Returns:
+            ``True`` if a schema can be applied natively for 
``effective_model``.
+        """
+        return effective_model in _NATIVE_STRUCTURED_OUTPUT_MODELS
+
     def chat(
         self,
         messages: Sequence[ChatMessage],
@@ -107,12 +193,28 @@ class TongyiChatModelConnection(BaseChatModelConnection):
     ) -> ChatMessage:
         """Process a sequence of messages, and return a response.
 
-        A non-``None`` ``output_schema`` is rejected: this connection has no 
native
-        structured-output translation, so callers stay on the 
prompt-engineering
-        fallback. Declaring the parameter keeps a caller-supplied schema out of
-        ``**kwargs``, which is forwarded to the provider SDK.
+        Parameters
+        ----------
+        messages : Sequence[ChatMessage]
+            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. Native structured output is applied only 
for a
+            ``BaseModel`` schema on a model the provider documents as capable; 
a
+            ``RowTypeInfo`` schema or an incapable model keeps the 
prompt-engineering
+            fallback. A ``response_format`` supplied alongside a schema is 
refused
+            rather than resolved.
+        **kwargs : Any
+            Additional parameters passed to the model service (e.g., 
temperature,
+            max_tokens, etc.)
+
+        Returns:
+        -------
+        ChatMessage
+            Model response message.
         """
-        self._reject_unsupported_output_schema(output_schema)
         tongyi_messages = self.__convert_to_tongyi_messages(messages)
 
         tongyi_tools: List[Dict[str, Any]] | None = (
@@ -124,6 +226,40 @@ class TongyiChatModelConnection(BaseChatModelConnection):
         req_api_key = kwargs.pop("api_key", self.api_key)
 
         model_name = kwargs.pop("model", DEFAULT_MODEL)
+
+        # The predicate reads model_name rather than kwargs.get("model"): the 
key was
+        # popped on the line above, so a kwargs lookup would yield None on 
every call
+        # and report every model incapable.
+        #
+        # TODO(#912): the requested strategy is not visible here, so this check
+        # cannot tell an explicit NATIVE request apart from one that merely
+        # resolved to native. A caller asking for NATIVE on a model this 
predicate
+        # rejects therefore gets an unconstrained response instead of an error.
+        # Once strategy resolution is wired up, NATIVE must either bypass this
+        # capability check or fail explicitly.
+        if output_schema is not None and 
self.supports_native_structured_output(
+            model_name
+        ):
+            # Resolved before the conflict test, so a payload with no native
+            # translation does not raise over a response_format this branch was
+            # never going to write. Tested before the schema is rendered, 
because 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.
+            native_model = _native_output_model(output_schema)
+            if native_model is not None and "response_format" in kwargs:
+                msg = (
+                    f"The {native_model.__name__} output schema is sent as "
+                    f"response_format to model '{model_name}', so 
response_format "
+                    f"must not also be passed as a kwarg. Remove that value, 
or "
+                    f"omit output_schema to set response_format directly."
+                )
+                raise ValueError(msg)
+            response_format = _native_response_format(output_schema)
+            if response_format is not None:
+                kwargs["response_format"] = response_format
+
         response = Generation.call(
             model=model_name,
             messages=tongyi_messages,

Reply via email to