From 07c6f2e76fb5377986d0f8e0a28c78af3e6564e6 Mon Sep 17 00:00:00 2001 From: sukeesh Date: Tue, 20 Jan 2026 16:15:59 +0530 Subject: [PATCH 1/5] fix(anthropic): Add response_format support for structured outputs --- .../agent_framework_anthropic/_chat_client.py | 96 +++++++++++++++---- 1 file changed, 79 insertions(+), 17 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index a5b169fbbf..2f3078a3fb 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import json from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence from typing import Any, ClassVar, Final, TypeVar @@ -45,12 +46,13 @@ BetaTextBlock, BetaUsage, ) -from pydantic import SecretStr, ValidationError +from pydantic import BaseModel, SecretStr, ValidationError logger = get_logger("agent_framework.anthropic") ANTHROPIC_DEFAULT_MAX_TOKENS: Final[int] = 1024 BETA_FLAGS: Final[list[str]] = ["mcp-client-2025-04-04", "code-execution-2025-08-25"] +RESPONSE_FORMAT_TOOL_NAME: Final[str] = "_response_format_tool" ROLE_MAP: dict[Role, str] = { Role.USER: "user", @@ -218,7 +220,7 @@ async def _inner_get_response( # execute message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) # process - return self._process_message(message) + return self._process_message(message, chat_options) async def _inner_get_streaming_response( self, @@ -259,6 +261,7 @@ def _prepare_options( "instructions", # handled via system message "tool_choice", # handled separately "allow_multiple_tool_calls", # handled via tool_choice + "response_format", # handled separately "additional_properties", # handled separately } ) @@ -299,6 +302,15 @@ def _prepare_options( if tools_config := self._prepare_tools_for_anthropic(chat_options): run_options.update(tools_config) + # response_format - use tool-based approach for structured outputs + if chat_options.response_format is not None: + response_format_tool = self._create_response_format_tool(chat_options.response_format) + if "tools" not in run_options: + run_options["tools"] = [] + run_options["tools"].append(response_format_tool) + # Force the model to use this tool + run_options["tool_choice"] = {"type": "tool", "name": RESPONSE_FORMAT_TOOL_NAME} + # additional properties additional_options = { key: value @@ -325,6 +337,30 @@ def _prepare_betas(self, chat_options: ChatOptions) -> set[str]: *chat_options.additional_properties.get("additional_beta_flags", []), } + def _create_response_format_tool(self, response_format: type[BaseModel]) -> dict[str, Any]: + """Create a tool definition from a Pydantic model for structured output. + + Args: + response_format: The Pydantic model class to use as the response format. + + Returns: + A dictionary representing the tool definition for Anthropic. + """ + schema = response_format.model_json_schema() + # Remove $defs from schema and inline them if needed - Anthropic may not support $ref + if "$defs" in schema: + # For simple cases, just remove $defs as most response formats are flat + schema.pop("$defs", None) + + return { + "type": "custom", + "name": RESPONSE_FORMAT_TOOL_NAME, + "description": ( + f"Use this tool to provide your response in the required structured format: {response_format.__name__}" + ), + "input_schema": schema, + } + def _prepare_messages_for_anthropic(self, messages: MutableSequence[ChatMessage]) -> list[dict[str, Any]]: """Prepare a list of ChatMessages for the Anthropic client. @@ -484,11 +520,12 @@ def _prepare_tools_for_anthropic(self, chat_options: ChatOptions) -> dict[str, A # region Response Processing Methods - def _process_message(self, message: BetaMessage) -> ChatResponse: + def _process_message(self, message: BetaMessage, chat_options: ChatOptions) -> ChatResponse: """Process the response from the Anthropic client. Args: message: The message returned by the Anthropic client. + chat_options: The chat options used for the request. Returns: A ChatResponse object containing the processed response. @@ -505,6 +542,7 @@ def _process_message(self, message: BetaMessage) -> ChatResponse: usage_details=self._parse_usage_from_anthropic(message.usage), model_id=message.model, finish_reason=FINISH_REASON_MAP.get(message.stop_reason) if message.stop_reason else None, + response_format=chat_options.response_format, raw_response=message, ) @@ -588,14 +626,29 @@ def _parse_contents_from_anthropic( ) case "tool_use" | "mcp_tool_use" | "server_tool_use": self._last_call_id_name = (content_block.id, content_block.name) - contents.append( - FunctionCallContent( - call_id=content_block.id, - name=content_block.name, - arguments=content_block.input, - raw_representation=content_block, + # Check if this is the response_format tool - convert to text for structured output parsing + if content_block.name == RESPONSE_FORMAT_TOOL_NAME: + # Convert the tool arguments to JSON text so try_parse_value can parse it + json_text = ( + json.dumps(content_block.input) + if isinstance(content_block.input, dict) + else str(content_block.input) + ) + contents.append( + TextContent( + text=json_text, + raw_representation=content_block, + ) + ) + else: + contents.append( + FunctionCallContent( + call_id=content_block.id, + name=content_block.name, + arguments=content_block.input, + raw_representation=content_block, + ) ) - ) case "mcp_tool_result": call_id, name = self._last_call_id_name or (None, None) contents.append( @@ -647,14 +700,23 @@ def _parse_contents_from_anthropic( ) case "input_json_delta": call_id, name = self._last_call_id_name if self._last_call_id_name else ("", "") - contents.append( - FunctionCallContent( - call_id=call_id, - name=name, - arguments=content_block.partial_json, - raw_representation=content_block, + # Check if this is the response_format tool - convert to text for structured output parsing + if name == RESPONSE_FORMAT_TOOL_NAME: + contents.append( + TextContent( + text=content_block.partial_json, + raw_representation=content_block, + ) + ) + else: + contents.append( + FunctionCallContent( + call_id=call_id, + name=name, + arguments=content_block.partial_json, + raw_representation=content_block, + ) ) - ) case "thinking" | "thinking_delta": contents.append(TextReasoningContent(text=content_block.thinking, raw_representation=content_block)) case _: From 4cade3938883defe534b3fe2c889a73df2662743 Mon Sep 17 00:00:00 2001 From: sukeesh Date: Tue, 20 Jan 2026 19:52:49 +0530 Subject: [PATCH 2/5] only use from options --- .../agent_framework_anthropic/_chat_client.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 2194f44e60..bbdfac55fb 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -354,9 +354,8 @@ async def _inner_get_response( run_options = self._prepare_options(messages, options, **kwargs) # execute message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) - # process - merge response_format from kwargs if present - merged_options = {**options, **{k: v for k, v in kwargs.items() if k == "response_format"}} - return self._process_message(message, merged_options) + # process + return self._process_message(message, options) @override async def _inner_get_streaming_response( @@ -444,8 +443,7 @@ def _prepare_options( run_options.update(tools_config) # response_format - use tool-based approach for structured outputs - # response_format can come from options or kwargs - response_format = options.get("response_format") or kwargs.get("response_format") + response_format = options.get("response_format") if response_format is not None: response_format_tool = self._create_response_format_tool(response_format) if "tools" not in run_options: @@ -454,9 +452,7 @@ def _prepare_options( # Force the model to use this tool run_options["tool_choice"] = {"type": "tool", "name": RESPONSE_FORMAT_TOOL_NAME} - # Filter out response_format from kwargs since we handle it separately - filtered_kwargs = {k: v for k, v in kwargs.items() if k != "response_format"} - run_options.update(filtered_kwargs) + run_options.update(kwargs) return run_options def _prepare_betas(self, options: dict[str, Any]) -> set[str]: From 6663617ca63ffb7a30af041fca424fc71b3f901e Mon Sep 17 00:00:00 2001 From: sukeesh Date: Tue, 20 Jan 2026 20:47:05 +0530 Subject: [PATCH 3/5] use native way of response format --- .../agent_framework_anthropic/_chat_client.py | 73 +++++-------------- .../anthropic/tests/test_anthropic_client.py | 4 +- 2 files changed, 22 insertions(+), 55 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index bbdfac55fb..b16b71cd3d 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -1,6 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -import json import sys from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence from typing import Any, ClassVar, Final, Generic, Literal, TypedDict @@ -80,7 +79,7 @@ ANTHROPIC_DEFAULT_MAX_TOKENS: Final[int] = 1024 BETA_FLAGS: Final[list[str]] = ["mcp-client-2025-04-04", "code-execution-2025-08-25"] -RESPONSE_FORMAT_TOOL_NAME: Final[str] = "_response_format_tool" +STRUCTURED_OUTPUTS_BETA_FLAG: Final[str] = "structured-outputs-2025-11-13" # region Anthropic Chat Options TypedDict @@ -442,15 +441,12 @@ def _prepare_options( if tools_config := self._prepare_tools_for_anthropic(options): run_options.update(tools_config) - # response_format - use tool-based approach for structured outputs + # response_format - use native output_format for structured outputs response_format = options.get("response_format") if response_format is not None: - response_format_tool = self._create_response_format_tool(response_format) - if "tools" not in run_options: - run_options["tools"] = [] - run_options["tools"].append(response_format_tool) - # Force the model to use this tool - run_options["tool_choice"] = {"type": "tool", "name": RESPONSE_FORMAT_TOOL_NAME} + run_options["output_format"] = self._prepare_response_format(response_format) + # Add the structured outputs beta flag + run_options["betas"].add(STRUCTURED_OUTPUTS_BETA_FLAG) run_options.update(kwargs) return run_options @@ -470,28 +466,22 @@ def _prepare_betas(self, options: dict[str, Any]) -> set[str]: *options.get("additional_beta_flags", []), } - def _create_response_format_tool(self, response_format: type[BaseModel]) -> dict[str, Any]: - """Create a tool definition from a Pydantic model for structured output. + def _prepare_response_format(self, response_format: type[BaseModel]) -> dict[str, Any]: + """Prepare the output_format parameter from a Pydantic model for structured output. Args: response_format: The Pydantic model class to use as the response format. Returns: - A dictionary representing the tool definition for Anthropic. + A dictionary representing the output_format for Anthropic's structured outputs. """ schema = response_format.model_json_schema() - # Remove $defs from schema and inline them if needed - Anthropic may not support $ref - if "$defs" in schema: - # For simple cases, just remove $defs as most response formats are flat - schema.pop("$defs", None) + # Ensure additionalProperties is false as required by Anthropic + schema["additionalProperties"] = False return { - "type": "custom", - "name": RESPONSE_FORMAT_TOOL_NAME, - "description": ( - f"Use this tool to provide your response in the required structured format: {response_format.__name__}" - ), - "input_schema": schema, + "type": "json_schema", + "schema": schema, } def _prepare_messages_for_anthropic(self, messages: MutableSequence[ChatMessage]) -> list[dict[str, Any]]: @@ -767,21 +757,7 @@ def _parse_contents_from_anthropic( ) case "tool_use" | "mcp_tool_use" | "server_tool_use": self._last_call_id_name = (content_block.id, content_block.name) - # Check if this is the response_format tool - convert to text for structured output parsing - if content_block.name == RESPONSE_FORMAT_TOOL_NAME: - # Convert the tool arguments to JSON text so try_parse_value can parse it - json_text = ( - json.dumps(content_block.input) - if isinstance(content_block.input, dict) - else str(content_block.input) - ) - contents.append( - TextContent( - text=json_text, - raw_representation=content_block, - ) - ) - elif content_block.type == "mcp_tool_use": + if content_block.type == "mcp_tool_use": contents.append( MCPServerToolCallContent( call_id=content_block.id, @@ -1034,23 +1010,14 @@ def _parse_contents_from_anthropic( # provides the name, so deltas should only carry incremental arguments. # This matches OpenAI's behavior where streaming chunks have name="". call_id, name = self._last_call_id_name if self._last_call_id_name else ("", "") - # Check if this is the response_format tool - convert to text for structured output parsing - if name == RESPONSE_FORMAT_TOOL_NAME: - contents.append( - TextContent( - text=content_block.partial_json, - raw_representation=content_block, - ) - ) - else: - contents.append( - FunctionCallContent( - call_id=call_id, - name="", - arguments=content_block.partial_json, - raw_representation=content_block, - ) + contents.append( + FunctionCallContent( + call_id=call_id, + name="", + arguments=content_block.partial_json, + raw_representation=content_block, ) + ) case "thinking" | "thinking_delta": contents.append( TextReasoningContent( diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 828d9916c2..a4e33e72dc 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -500,7 +500,7 @@ def test_process_message_basic(mock_anthropic_client: MagicMock) -> None: mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5) mock_message.stop_reason = "end_turn" - response = chat_client._process_message(mock_message) + response = chat_client._process_message(mock_message, {}) assert response.response_id == "msg_123" assert response.model_id == "claude-3-5-sonnet-20241022" @@ -533,7 +533,7 @@ def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5) mock_message.stop_reason = "tool_use" - response = chat_client._process_message(mock_message) + response = chat_client._process_message(mock_message, {}) assert len(response.messages[0].contents) == 1 assert isinstance(response.messages[0].contents[0], FunctionCallContent) From 949afb48c0c079bef5f44cdffb3f60a0dc18df4b Mon Sep 17 00:00:00 2001 From: sukeesh Date: Wed, 21 Jan 2026 08:16:17 +0530 Subject: [PATCH 4/5] ruff lint fix --- .../anthropic/agent_framework_anthropic/_chat_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 2beb47a065..9089e08dca 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -1000,7 +1000,7 @@ def _parse_contents_from_anthropic( # since it triggers on `if content.name:`. The initial tool_use event already # provides the name, so deltas should only carry incremental arguments. # This matches OpenAI's behavior where streaming chunks have name="". - call_id, name = self._last_call_id_name if self._last_call_id_name else ("", "") + call_id, _name = self._last_call_id_name if self._last_call_id_name else ("", "") contents.append( Content.from_function_call( call_id=call_id, From 10a5e21c78973624523fe31bdacb50aceb2768fb Mon Sep 17 00:00:00 2001 From: sukeesh Date: Wed, 21 Jan 2026 20:26:54 +0530 Subject: [PATCH 5/5] address comment; handle dict --- .../agent_framework_anthropic/_chat_client.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 9089e08dca..2fc75799a6 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -454,17 +454,34 @@ def _prepare_betas(self, options: dict[str, Any]) -> set[str]: *options.get("additional_beta_flags", []), } - def _prepare_response_format(self, response_format: type[BaseModel]) -> dict[str, Any]: - """Prepare the output_format parameter from a Pydantic model for structured output. + def _prepare_response_format(self, response_format: type[BaseModel] | dict[str, Any]) -> dict[str, Any]: + """Prepare the output_format parameter for structured output. Args: - response_format: The Pydantic model class to use as the response format. + response_format: Either a Pydantic model class or a dict with the schema specification. + If a dict, it can be in OpenAI-style format with "json_schema" key, + or direct format with "schema" key, or the raw schema dict itself. Returns: A dictionary representing the output_format for Anthropic's structured outputs. """ + if isinstance(response_format, dict): + if "json_schema" in response_format: + schema = response_format["json_schema"].get("schema", {}) + elif "schema" in response_format: + schema = response_format["schema"] + else: + schema = response_format + + if isinstance(schema, dict): + schema["additionalProperties"] = False + + return { + "type": "json_schema", + "schema": schema, + } + schema = response_format.model_json_schema() - # Ensure additionalProperties is false as required by Anthropic schema["additionalProperties"] = False return {