diff --git a/docs/content/docs/development/chat_models.md b/docs/content/docs/development/chat_models.md index d9a21f96c..fcd24e11e 100644 --- a/docs/content/docs/development/chat_models.md +++ b/docs/content/docs/development/chat_models.md @@ -1078,12 +1078,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 the Qwen3.7-Max family, the schema-capable family 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..64a816edc --- /dev/null +++ b/python/flink_agents/integrations/chat_models/tests/test_tongyi_native_structured_output.py @@ -0,0 +1,256 @@ +################################################################################ +# 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 model family 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-2026-05-20"] + +# Names that must not be treated as capable. qwen-plus is the connection's default +# model, qwen3.7-maximum is the near miss a bare prefix test would admit, and +# qwen3.8-max is a family reachable only through a different endpoint. +_INCAPABLE_MODELS = [ + "qwen-plus", + "qwen-turbo", + "qwen3.8-max", + "qwen3.7-maximum", + "", + 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), + ) + + +@pytest.mark.parametrize("model", _CAPABLE_MODELS) +def test_capability_predicate_accepts_capable_models(model: str) -> None: + """The family alias and a dated snapshot below it are both capable.""" + assert _connection().supports_native_structured_output(model) is True + + +@pytest.mark.parametrize("model", _INCAPABLE_MODELS) +def test_capability_predicate_rejects_incapable_models(model: str | None) -> None: + """Other families, a near-miss name, and an absent model name 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..3fe9c0cdf 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 @@ 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,73 @@ DEFAULT_REQUEST_TIMEOUT = 60.0 DEFAULT_MODEL = "qwen-plus" +# Models with documented json_schema support that are also served on the +# text-generation endpoint this connection calls. That intersection is the +# Qwen3.7-Max family: the other four json_schema families (Qwen3.7-Plus, +# Qwen3.7-Flash, Qwen3.8-Flash, Qwen3.8-Max) route to the multimodal endpoint and +# answer Generation.call with "url error". +# 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 +# +# Capability is documented per family, meaning the base name plus the dated +# snapshots behind it, so a name matches the prefix itself or a name continuing it +# after a "-" separator. That expresses the documented unit instead of a snapshot +# census that goes stale, and it keeps out a different family that merely extends +# the prefix, such as qwen3.7-maximum. The one snapshot the rule admits without +# json_schema reaching it, qwen3.7-max-2026-06-08, is multimodal-routed and answers +# this connection with "url error" whether or not a response_format rides along. +# +# A name outside the rule reports not-capable and degrades to the prompt-engineering +# fallback rather than failing at the provider. +_NATIVE_STRUCTURED_OUTPUT_ALIAS_PREFIXES = ("qwen3.7-max",) + + +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 +166,29 @@ def __init__( **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 capability + is matched by family prefix. 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``. + """ + if not effective_model: + return False + return any( + effective_model == prefix or effective_model.startswith(prefix + "-") + for prefix in _NATIVE_STRUCTURED_OUTPUT_ALIAS_PREFIXES + ) + def chat( self, messages: Sequence[ChatMessage], @@ -107,12 +198,28 @@ def chat( ) -> 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 +231,40 @@ def chat( 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,