From c2bd5f53b1d543a2cbee990d39e173977b35efad Mon Sep 17 00:00:00 2001 From: Anjan <743179+t-anjan@users.noreply.github.com> Date: Fri, 15 May 2026 08:54:19 +0530 Subject: [PATCH] Python (anthropic): Migrate structured outputs to GA `output_config.format` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug --- `RawAnthropicClient._prepare_options` forwards `response_format` as the **deprecated** beta parameter `output_format={"type": "json_schema", "schema": {...}}` plus the beta flag `structured-outputs-2025-11-13`. When the same request also includes `tools`, Claude emits concatenated / malformed JSON — e.g. three copies of the schema's empty default like `{"matches":[]}{"matches":[]}{"matches":[]}` — instead of populating the schema. Anthropic's GA shape — `output_config={"format": {"type": "json_schema", "schema": {...}}}` — works correctly with tools. Verified empirically on `agent-framework-anthropic` against `claude-sonnet-4-6` for a structured-output workload that combined `response_format` with a tool (`run_shell`); the deprecated path produced the malformed concatenated output, the GA path did not. Changes ------- - Move `response_format` into `run_options["output_config"]["format"]` and stop adding the `structured-outputs-2025-11-13` beta flag (the GA path doesn't need it). - Merge the format into any caller-supplied `output_config` so e.g. `output_config["effort"]` (adaptive-thinking effort level) survives the transformation. - Drop the now-unused `STRUCTURED_OUTPUTS_BETA_FLAG` constant (private to this module — no external callers). - `_prepare_response_format` keeps the same `{"type": "json_schema", "schema": ...}` return shape; the docstring is updated to point at the GA target. Test plan --------- - `uv run pytest packages/anthropic/tests` → 130 passed. - New tests: - `test_prepare_options_uses_output_config_for_response_format` — the GA `output_config.format` shape is emitted, the deprecated `output_format` key is not, and the `structured-outputs-2025-11-13` beta flag is not added. - `test_prepare_options_preserves_caller_supplied_output_config_effort` — a caller-supplied `output_config["effort"]` survives the merge. - `test_prepare_options_no_response_format_omits_output_config` — no `output_config` is added implicitly when `response_format` is absent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agent_framework_anthropic/_chat_client.py | 19 ++++-- .../anthropic/tests/test_anthropic_client.py | 68 +++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 98c181f152..65f23af44f 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -76,7 +76,6 @@ ANTHROPIC_DEFAULT_MAX_TOKENS: Final[int] = 1024 BETA_FLAGS: Final[list[str]] = ["mcp-client-2025-04-04", "code-execution-2025-08-25"] -STRUCTURED_OUTPUTS_BETA_FLAG: Final[str] = "structured-outputs-2025-11-13" ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) AnthropicAsyncClient = AsyncAnthropic | AsyncAnthropicBedrock | AsyncAnthropicFoundry | AsyncAnthropicVertex @@ -632,12 +631,17 @@ def _prepare_options( if tools_config := self._prepare_tools_for_anthropic(options): run_options.update(tools_config) - # response_format - use native output_format for structured outputs + # response_format - emit Anthropic's GA ``output_config.format`` shape. + # The deprecated ``output_format`` parameter (gated by the + # ``structured-outputs-2025-11-13`` beta flag) produced concatenated / + # malformed JSON when combined with tools — the GA path does not. + # Merge into any caller-supplied ``output_config`` so e.g. the + # adaptive-thinking ``effort`` setting survives the transformation. response_format = options.get("response_format") if response_format is not None: - run_options["output_format"] = self._prepare_response_format(response_format) - # Add the structured outputs beta flag - run_options["betas"].add(STRUCTURED_OUTPUTS_BETA_FLAG) + output_config = dict(run_options.get("output_config") or {}) + output_config["format"] = self._prepare_response_format(response_format) + run_options["output_config"] = output_config return run_options @@ -657,7 +661,7 @@ def _prepare_betas(self, options: Mapping[str, Any]) -> set[str]: } def _prepare_response_format(self, response_format: type[BaseModel] | dict[str, Any]) -> dict[str, Any]: - """Prepare the output_format parameter for structured output. + """Build the ``output_config.format`` payload for Anthropic structured outputs. Args: response_format: Either a Pydantic model class or a dict with the schema specification. @@ -665,7 +669,8 @@ def _prepare_response_format(self, response_format: type[BaseModel] | dict[str, or direct format with "schema" key, or the raw schema dict itself. Returns: - A dictionary representing the output_format for Anthropic's structured outputs. + A ``{"type": "json_schema", "schema": ...}`` dict — the value placed + under ``output_config["format"]`` on the GA structured-outputs path. """ if isinstance(response_format, dict): if "json_schema" in response_format: diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 0cfec3423c..fcdedd4700 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -1800,6 +1800,74 @@ class TestModel(BaseModel): assert "properties" in result["schema"] +async def test_prepare_options_uses_output_config_for_response_format( + mock_anthropic_client: MagicMock, +) -> None: + """``response_format`` is forwarded as GA ``output_config.format`` (not the deprecated ``output_format``). + + The deprecated ``output_format`` parameter, gated by the + ``structured-outputs-2025-11-13`` beta flag, produced concatenated / + malformed JSON when combined with tools. The GA ``output_config`` shape + works correctly with tools, so we emit that and no longer set the beta + flag. + """ + + class StructuredOut(BaseModel): + answer: str + + client = create_test_anthropic_client(mock_anthropic_client) + messages = [Message(role="user", contents=["Hello"])] + chat_options = ChatOptions(max_tokens=100, response_format=StructuredOut) + + run_options = client._prepare_options(messages, chat_options) + + assert "output_format" not in run_options + assert "output_config" in run_options + fmt = run_options["output_config"]["format"] + assert fmt["type"] == "json_schema" + assert fmt["schema"]["additionalProperties"] is False + assert "answer" in fmt["schema"]["properties"] + # The deprecated structured-outputs beta flag is no longer needed on the + # GA path and must not leak into ``betas``. + assert "structured-outputs-2025-11-13" not in run_options["betas"] + + +async def test_prepare_options_preserves_caller_supplied_output_config_effort( + mock_anthropic_client: MagicMock, +) -> None: + """A caller-supplied ``output_config.effort`` (e.g. adaptive thinking) survives the format merge.""" + + class StructuredOut(BaseModel): + answer: str + + client = create_test_anthropic_client(mock_anthropic_client) + messages = [Message(role="user", contents=["Hello"])] + # ``output_config`` is provider-specific; pass it through additional kwargs + # the way a caller would when configuring adaptive thinking. + run_options = client._prepare_options( + messages, + ChatOptions(max_tokens=100, response_format=StructuredOut), + output_config={"effort": "high"}, + ) + + output_config = run_options["output_config"] + assert output_config["effort"] == "high" + assert output_config["format"]["type"] == "json_schema" + assert "answer" in output_config["format"]["schema"]["properties"] + + +async def test_prepare_options_no_response_format_omits_output_config( + mock_anthropic_client: MagicMock, +) -> None: + """Without ``response_format``, no ``output_config`` is added implicitly.""" + client = create_test_anthropic_client(mock_anthropic_client) + messages = [Message(role="user", contents=["Hello"])] + run_options = client._prepare_options(messages, ChatOptions(max_tokens=100)) + + assert "output_config" not in run_options + assert "output_format" not in run_options + + # Message Preparation Tests