mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: feat(anthropic): Add response_format support for structured outputs (#3301)
* fix(anthropic): Add response_format support for structured outputs * only use from options * use native way of response format * ruff lint fix * address comment; handle dict
This commit is contained in:
committed by
GitHub
Unverified
parent
6f1ab66795
commit
082f39e77e
@@ -45,7 +45,7 @@ from anthropic.types.beta.beta_bash_code_execution_tool_result_error import (
|
||||
from anthropic.types.beta.beta_code_execution_tool_result_error import (
|
||||
BetaCodeExecutionToolResultError,
|
||||
)
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from pydantic import BaseModel, SecretStr, ValidationError
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar
|
||||
@@ -67,6 +67,7 @@ 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"]
|
||||
STRUCTURED_OUTPUTS_BETA_FLAG: Final[str] = "structured-outputs-2025-11-13"
|
||||
|
||||
|
||||
# region Anthropic Chat Options TypedDict
|
||||
@@ -341,7 +342,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
|
||||
# execute
|
||||
message = await self.anthropic_client.beta.messages.create(**run_options, stream=False)
|
||||
# process
|
||||
return self._process_message(message)
|
||||
return self._process_message(message, options)
|
||||
|
||||
@override
|
||||
async def _inner_get_streaming_response(
|
||||
@@ -384,8 +385,10 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
|
||||
|
||||
messages = prepend_instructions_to_messages(list(messages), instructions, role="system")
|
||||
|
||||
# Start with a copy of options
|
||||
run_options: dict[str, Any] = {k: v for k, v in options.items() if v is not None and k not in {"instructions"}}
|
||||
# Start with a copy of options, excluding keys we handle separately
|
||||
run_options: dict[str, Any] = {
|
||||
k: v for k, v in options.items() if v is not None and k not in {"instructions", "response_format"}
|
||||
}
|
||||
|
||||
# Translation between options keys and Anthropic Messages API
|
||||
for old_key, new_key in OPTION_TRANSLATIONS.items():
|
||||
@@ -426,6 +429,13 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
|
||||
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 = 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)
|
||||
|
||||
run_options.update(kwargs)
|
||||
return run_options
|
||||
|
||||
@@ -444,6 +454,41 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
|
||||
*options.get("additional_beta_flags", []),
|
||||
}
|
||||
|
||||
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: 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()
|
||||
schema["additionalProperties"] = False
|
||||
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"schema": schema,
|
||||
}
|
||||
|
||||
def _prepare_messages_for_anthropic(self, messages: MutableSequence[ChatMessage]) -> list[dict[str, Any]]:
|
||||
"""Prepare a list of ChatMessages for the Anthropic client.
|
||||
|
||||
@@ -606,11 +651,12 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
|
||||
|
||||
# region Response Processing Methods
|
||||
|
||||
def _process_message(self, message: BetaMessage) -> ChatResponse:
|
||||
def _process_message(self, message: BetaMessage, options: dict[str, Any]) -> ChatResponse:
|
||||
"""Process the response from the Anthropic client.
|
||||
|
||||
Args:
|
||||
message: The message returned by the Anthropic client.
|
||||
options: The options dict used for the request.
|
||||
|
||||
Returns:
|
||||
A ChatResponse object containing the processed response.
|
||||
@@ -627,6 +673,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
|
||||
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=options.get("response_format"),
|
||||
raw_representation=message,
|
||||
)
|
||||
|
||||
@@ -970,7 +1017,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
|
||||
# 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, _ = 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,
|
||||
|
||||
@@ -495,7 +495,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"
|
||||
@@ -528,7 +528,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 response.messages[0].contents[0].type == "function_call"
|
||||
|
||||
Reference in New Issue
Block a user