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,