Python: [BREAKING]: Introducing Options as TypedDict and Generic (#3140)

* WIP typeddict for options

* updated all clients and ChatAgents

* updated everything

* added ADR

* fix mypy

* proper typevar imports

* fixed import

* fixed other imports

* slight update in the sample

* updated from feedback

* fixes

* fixed missing covariants and test fixes

* fixed typing

* updated anthropic thinking config

* ruff fixes

* fixed int tests

* fix tests and mypy

* updated integration tests

* updated docstring and test fix

* improved options handling in obser

* mypy fix

* updated a host of integration tests

* fix tests

* bedrock fix
This commit is contained in:
Eduard van Valkenburg
2026-01-13 16:41:05 +00:00
committed by GitHub
parent 5faa2851bb
commit 3e97425245
111 changed files with 6141 additions and 4715 deletions
@@ -2,7 +2,7 @@
import importlib.metadata
from ._chat_client import AnthropicClient
from ._chat_client import AnthropicChatOptions, AnthropicClient
try:
__version__ = importlib.metadata.version(__name__)
@@ -10,6 +10,7 @@ except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"AnthropicChatOptions",
"AnthropicClient",
"__version__",
]
@@ -1,6 +1,8 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence
from typing import Any, ClassVar, Final, TypeVar
from typing import Any, ClassVar, Final, Generic, Literal, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
@@ -49,15 +51,132 @@ from anthropic.types.beta import (
BetaTextBlock,
BetaUsage,
)
from anthropic.types.beta.beta_bash_code_execution_tool_result_error import BetaBashCodeExecutionToolResultError
from anthropic.types.beta.beta_code_execution_tool_result_error import BetaCodeExecutionToolResultError
from anthropic.types.beta.beta_bash_code_execution_tool_result_error import (
BetaBashCodeExecutionToolResultError,
)
from anthropic.types.beta.beta_code_execution_tool_result_error import (
BetaCodeExecutionToolResultError,
)
from pydantic import SecretStr, ValidationError
if sys.version_info >= (3, 13):
from typing import TypeVar
else:
from typing_extensions import TypeVar
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
__all__ = [
"AnthropicChatOptions",
"AnthropicClient",
"ThinkingConfig",
]
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"]
# region Anthropic Chat Options TypedDict
class ThinkingConfig(TypedDict, total=False):
"""Configuration for enabling Claude's extended thinking.
When enabled, responses include ``thinking`` content blocks showing Claude's
thinking process before the final answer. Requires a minimum budget of 1,024
tokens and counts towards your ``max_tokens`` limit.
See https://docs.claude.com/en/docs/build-with-claude/extended-thinking for details.
Keys:
type: "enabled" to enable extended thinking, "disabled" to disable.
budget_tokens: The token budget for thinking (minimum 1024, required when type="enabled").
"""
type: Literal["enabled", "disabled"]
budget_tokens: int
class AnthropicChatOptions(ChatOptions, total=False):
"""Anthropic-specific chat options.
Extends ChatOptions with options specific to Anthropic's Messages API.
Options that Anthropic doesn't support are typed as None to indicate they're unavailable.
Note:
Anthropic REQUIRES max_tokens to be specified. If not provided,
a default of 1024 will be used.
Keys:
model_id: The model to use for the request,
translates to ``model`` in Anthropic API.
temperature: Sampling temperature between 0 and 1.
top_p: Nucleus sampling parameter.
max_tokens: Maximum number of tokens to generate (REQUIRED).
stop: Stop sequences,
translates to ``stop_sequences`` in Anthropic API.
tools: List of tools (functions) available to the model.
tool_choice: How the model should use tools.
response_format: Structured output schema.
metadata: Request metadata with user_id for tracking.
user: User identifier, translates to ``metadata.user_id`` in Anthropic API.
instructions: System instructions for the model,
translates to ``system`` in Anthropic API.
top_k: Number of top tokens to consider for sampling.
service_tier: Service tier ("auto" or "standard_only").
thinking: Extended thinking configuration for Claude models.
When enabled, responses include ``thinking`` content blocks showing Claude's
thinking process before the final answer. Requires a minimum budget of 1,024
tokens and counts towards your ``max_tokens`` limit.
See https://docs.claude.com/en/docs/build-with-claude/extended-thinking for details.
container: Container configuration for skills.
additional_beta_flags: Additional beta flags to enable on the request.
"""
# Anthropic-specific generation parameters (supported by all models)
top_k: int
service_tier: Literal["auto", "standard_only"]
# Extended thinking (Claude models)
thinking: ThinkingConfig
# Skills
container: dict[str, Any]
# Beta features
additional_beta_flags: list[str]
# Unsupported base options (override with None to indicate not supported)
logit_bias: None # type: ignore[misc]
seed: None # type: ignore[misc]
frequency_penalty: None # type: ignore[misc]
presence_penalty: None # type: ignore[misc]
store: None # type: ignore[misc]
TAnthropicOptions = TypeVar(
"TAnthropicOptions",
bound=TypedDict, # type: ignore[valid-type]
default="AnthropicChatOptions",
covariant=True,
)
# Translation between framework options keys and Anthropic Messages API
OPTION_TRANSLATIONS: dict[str, str] = {
"model_id": "model",
"stop": "stop_sequences",
"instructions": "system",
}
# region Role and Finish Reason Maps
ROLE_MAP: dict[Role, str] = {
Role.USER: "user",
Role.ASSISTANT: "assistant",
@@ -111,13 +230,10 @@ class AnthropicSettings(AFBaseSettings):
chat_model_id: str | None = None
TAnthropicClient = TypeVar("TAnthropicClient", bound="AnthropicClient")
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class AnthropicClient(BaseChatClient):
class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptions]):
"""Anthropic Chat client."""
OTEL_PROVIDER_NAME: ClassVar[str] = "anthropic" # type: ignore[reportIncompatibleVariableOverride, misc]
@@ -177,6 +293,18 @@ class AnthropicClient(BaseChatClient):
anthropic_client=anthropic_client,
)
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework.anthropic import AnthropicChatOptions
class MyOptions(AnthropicChatOptions, total=False):
my_custom_option: str
client: AnthropicClient[MyOptions] = AnthropicClient(model_id="claude-sonnet-4-5-20250929")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
anthropic_settings = AnthropicSettings(
@@ -212,29 +340,31 @@ class AnthropicClient(BaseChatClient):
# region Get response methods
@override
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
# prepare
run_options = self._prepare_options(messages, chat_options, **kwargs)
run_options = self._prepare_options(messages, options, **kwargs)
# execute
message = await self.anthropic_client.beta.messages.create(**run_options, stream=False)
# process
return self._process_message(message)
@override
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
# prepare
run_options = self._prepare_options(messages, chat_options, **kwargs)
run_options = self._prepare_options(messages, options, **kwargs)
# execute and process
async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True):
parsed_chunk = self._process_stream_event(chunk)
@@ -246,35 +376,31 @@ class AnthropicClient(BaseChatClient):
def _prepare_options(
self,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> dict[str, Any]:
"""Create run options for the Anthropic client based on messages and chat options.
"""Create run options for the Anthropic client based on messages and options.
Args:
messages: The list of chat messages.
chat_options: The chat options.
options: The options dict.
kwargs: Additional keyword arguments.
Returns:
A dictionary of run options for the Anthropic client.
"""
run_options: dict[str, Any] = chat_options.to_dict(
exclude={
"type",
"instructions", # handled via system message
"tool_choice", # handled separately
"allow_multiple_tool_calls", # handled via tool_choice
"additional_properties", # handled separately
}
)
# Prepend instructions from options if they exist
instructions = options.get("instructions")
if instructions:
from agent_framework._types import prepend_instructions_to_messages
# translations between ChatOptions and Anthropic API
translations = {
"model_id": "model",
"stop": "stop_sequences",
}
for old_key, new_key in translations.items():
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"}}
# Translation between options keys and Anthropic Messages API
for old_key, new_key in OPTION_TRANSLATIONS.items():
if old_key in run_options and old_key != new_key:
run_options[new_key] = run_options.pop(old_key)
@@ -296,31 +422,30 @@ class AnthropicClient(BaseChatClient):
run_options["system"] = messages[0].text
# betas
run_options["betas"] = self._prepare_betas(chat_options)
run_options["betas"] = self._prepare_betas(options)
# extra headers
run_options["extra_headers"] = {"User-Agent": AGENT_FRAMEWORK_USER_AGENT}
# Handle user option -> metadata.user_id (Anthropic uses metadata.user_id instead of user)
if user := run_options.pop("user", None):
metadata = run_options.get("metadata", {})
if "user_id" not in metadata:
metadata["user_id"] = user
run_options["metadata"] = metadata
# tools, mcp servers and tool choice
if tools_config := self._prepare_tools_for_anthropic(chat_options):
if tools_config := self._prepare_tools_for_anthropic(options):
run_options.update(tools_config)
# additional properties
additional_options = {
key: value
for key, value in chat_options.additional_properties.items()
if value is not None and key != "additional_beta_flags"
}
if additional_options:
run_options.update(additional_options)
run_options.update(kwargs)
return run_options
def _prepare_betas(self, chat_options: ChatOptions) -> set[str]:
def _prepare_betas(self, options: dict[str, Any]) -> set[str]:
"""Prepare the beta flags for the Anthropic API request.
Args:
chat_options: The chat options that may contain additional beta flags.
options: The options dict that may contain additional beta flags.
Returns:
A set of beta flag strings to include in the request.
@@ -328,7 +453,7 @@ class AnthropicClient(BaseChatClient):
return {
*BETA_FLAGS,
*self.additional_beta_flags,
*chat_options.additional_properties.get("additional_beta_flags", []),
*options.get("additional_beta_flags", []),
}
def _prepare_messages_for_anthropic(self, messages: MutableSequence[ChatMessage]) -> list[dict[str, Any]]:
@@ -370,7 +495,10 @@ class AnthropicClient(BaseChatClient):
logger.debug(f"Ignoring unsupported data content media type: {content.media_type} for now")
case "uri":
if content.has_top_level_media_type("image"):
a_content.append({"type": "image", "source": {"type": "url", "url": content.uri}})
a_content.append({
"type": "image",
"source": {"type": "url", "url": content.uri},
})
else:
logger.debug(f"Ignoring unsupported data content media type: {content.media_type} for now")
case "function_call":
@@ -397,22 +525,25 @@ class AnthropicClient(BaseChatClient):
"content": a_content,
}
def _prepare_tools_for_anthropic(self, chat_options: ChatOptions) -> dict[str, Any] | None:
def _prepare_tools_for_anthropic(self, options: dict[str, Any]) -> dict[str, Any] | None:
"""Prepare tools and tool choice configuration for the Anthropic API request.
Args:
chat_options: The chat options containing tools and tool choice settings.
options: The options dict containing tools and tool choice settings.
Returns:
A dictionary with tools, mcp_servers, and tool_choice configuration, or None if empty.
"""
from agent_framework._types import validate_tool_mode
result: dict[str, Any] = {}
tools = options.get("tools")
# Process tools
if chat_options.tools:
if tools:
tool_list: list[MutableMapping[str, Any]] = []
mcp_server_list: list[MutableMapping[str, Any]] = []
for tool in chat_options.tools:
for tool in tools:
match tool:
case MutableMapping():
tool_list.append(tool)
@@ -457,34 +588,31 @@ class AnthropicClient(BaseChatClient):
result["mcp_servers"] = mcp_server_list
# Process tool choice
if chat_options.tool_choice is not None:
tool_choice_mode = (
chat_options.tool_choice if isinstance(chat_options.tool_choice, str) else chat_options.tool_choice.mode
)
match tool_choice_mode:
case "auto":
tool_choice: dict[str, Any] = {"type": "auto"}
if chat_options.allow_multiple_tool_calls is not None:
tool_choice["disable_parallel_tool_use"] = not chat_options.allow_multiple_tool_calls
result["tool_choice"] = tool_choice
case "required":
if (
not isinstance(chat_options.tool_choice, str)
and chat_options.tool_choice.required_function_name
):
tool_choice = {
"type": "tool",
"name": chat_options.tool_choice.required_function_name,
}
else:
tool_choice = {"type": "any"}
if chat_options.allow_multiple_tool_calls is not None:
tool_choice["disable_parallel_tool_use"] = not chat_options.allow_multiple_tool_calls
result["tool_choice"] = tool_choice
case "none":
result["tool_choice"] = {"type": "none"}
case _:
logger.debug(f"Ignoring unsupported tool choice mode: {tool_choice_mode} for now")
if options.get("tool_choice") is None:
return result or None
tool_mode = validate_tool_mode(options.get("tool_choice"))
allow_multiple = options.get("allow_multiple_tool_calls")
match tool_mode.get("mode"):
case "auto":
tool_choice: dict[str, Any] = {"type": "auto"}
if allow_multiple is not None:
tool_choice["disable_parallel_tool_use"] = not allow_multiple
result["tool_choice"] = tool_choice
case "required":
if "required_function_name" in tool_mode:
tool_choice = {
"type": "tool",
"name": tool_mode["required_function_name"],
}
else:
tool_choice = {"type": "any"}
if allow_multiple is not None:
tool_choice["disable_parallel_tool_use"] = not allow_multiple
result["tool_choice"] = tool_choice
case "none":
result["tool_choice"] = {"type": "none"}
case _:
logger.debug(f"Ignoring unsupported tool choice mode: {tool_mode} for now")
return result or None
@@ -531,7 +659,10 @@ class AnthropicClient(BaseChatClient):
return ChatResponseUpdate(
response_id=event.message.id,
contents=[*self._parse_contents_from_anthropic(event.message.content), *usage_details],
contents=[
*self._parse_contents_from_anthropic(event.message.content),
*usage_details,
],
model_id=event.message.model,
finish_reason=FINISH_REASON_MAP.get(event.message.stop_reason)
if event.message.stop_reason
@@ -579,7 +710,8 @@ class AnthropicClient(BaseChatClient):
return usage_details
def _parse_contents_from_anthropic(
self, content: Sequence[BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock]
self,
content: Sequence[BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock],
) -> list[Contents]:
"""Parse contents from the Anthropic message."""
contents: list[Contents] = []
@@ -609,7 +741,12 @@ class AnthropicClient(BaseChatClient):
contents.append(
CodeInterpreterToolCallContent(
call_id=content_block.id,
inputs=[TextContent(text=str(content_block.input), raw_representation=content_block)],
inputs=[
TextContent(
text=str(content_block.input),
raw_representation=content_block,
)
],
raw_representation=content_block,
)
)
@@ -630,7 +767,10 @@ class AnthropicClient(BaseChatClient):
parsed_output = self._parse_contents_from_anthropic(content_block.content)
elif isinstance(content_block.content, (str, bytes)):
parsed_output = [
TextContent(text=str(content_block.content), raw_representation=content_block)
TextContent(
text=str(content_block.content),
raw_representation=content_block,
)
]
else:
parsed_output = self._parse_contents_from_anthropic([content_block.content])
@@ -679,7 +819,8 @@ class AnthropicClient(BaseChatClient):
for code_file_content in content_block.content.content:
code_outputs.append(
HostedFileContent(
file_id=code_file_content.file_id, raw_representation=code_file_content
file_id=code_file_content.file_id,
raw_representation=code_file_content,
)
)
contents.append(
@@ -720,7 +861,8 @@ class AnthropicClient(BaseChatClient):
for bash_file_content in content_block.content.content:
contents.append(
HostedFileContent(
file_id=bash_file_content.file_id, raw_representation=bash_file_content
file_id=bash_file_content.file_id,
raw_representation=bash_file_content,
)
)
contents.append(
@@ -847,7 +989,12 @@ class AnthropicClient(BaseChatClient):
)
)
case "thinking" | "thinking_delta":
contents.append(TextReasoningContent(text=content_block.thinking, raw_representation=content_block))
contents.append(
TextReasoningContent(
text=content_block.thinking,
raw_representation=content_block,
)
)
case _:
logger.debug(f"Ignoring unsupported content type: {content_block.type} for now")
return contents
@@ -870,7 +1017,10 @@ class AnthropicClient(BaseChatClient):
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
TextSpanRegion(start_index=citation.start_char_index, end_index=citation.end_char_index)
TextSpanRegion(
start_index=citation.start_char_index,
end_index=citation.end_char_index,
)
)
case "page_location":
cit.title = citation.document_title
@@ -893,7 +1043,10 @@ class AnthropicClient(BaseChatClient):
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
TextSpanRegion(start_index=citation.start_block_index, end_index=citation.end_block_index)
TextSpanRegion(
start_index=citation.start_block_index,
end_index=citation.end_block_index,
)
)
case "web_search_result_location":
cit.title = citation.title
@@ -906,7 +1059,10 @@ class AnthropicClient(BaseChatClient):
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
TextSpanRegion(start_index=citation.start_block_index, end_index=citation.end_block_index)
TextSpanRegion(
start_index=citation.start_block_index,
end_index=citation.end_block_index,
)
)
case _:
logger.debug(f"Unknown citation type encountered: {citation.type}")
@@ -677,7 +677,7 @@ async def test_inner_get_response(mock_anthropic_client: MagicMock) -> None:
chat_options = ChatOptions(max_tokens=10)
response = await chat_client._inner_get_response( # type: ignore[attr-defined]
messages=messages, chat_options=chat_options
messages=messages, options=chat_options
)
assert response is not None
@@ -702,7 +702,7 @@ async def test_inner_get_streaming_response(mock_anthropic_client: MagicMock) ->
chunks: list[ChatResponseUpdate] = []
async for chunk in chat_client._inner_get_streaming_response( # type: ignore[attr-defined]
messages=messages, chat_options=chat_options
messages=messages, options=chat_options
):
if chunk:
chunks.append(chunk)
@@ -730,7 +730,7 @@ async def test_anthropic_client_integration_basic_chat() -> None:
messages = [ChatMessage(role=Role.USER, text="Say 'Hello, World!' and nothing else.")]
response = await client.get_response(messages=messages, chat_options=ChatOptions(max_tokens=50))
response = await client.get_response(messages=messages, options={"max_tokens": 50})
assert response is not None
assert len(response.messages) > 0
@@ -748,7 +748,7 @@ async def test_anthropic_client_integration_streaming_chat() -> None:
messages = [ChatMessage(role=Role.USER, text="Count from 1 to 5.")]
chunks = []
async for chunk in client.get_streaming_response(messages=messages, chat_options=ChatOptions(max_tokens=50)):
async for chunk in client.get_streaming_response(messages=messages, options={"max_tokens": 50}):
chunks.append(chunk)
assert len(chunks) > 0
@@ -766,7 +766,7 @@ async def test_anthropic_client_integration_function_calling() -> None:
response = await client.get_response(
messages=messages,
chat_options=ChatOptions(tools=tools, max_tokens=100),
options={"tools": tools, "max_tokens": 100},
)
assert response is not None
@@ -796,7 +796,7 @@ async def test_anthropic_client_integration_hosted_tools() -> None:
response = await client.get_response(
messages=messages,
chat_options=ChatOptions(tools=tools, max_tokens=100),
options={"tools": tools, "max_tokens": 100},
)
assert response is not None
@@ -814,7 +814,7 @@ async def test_anthropic_client_integration_with_system_message() -> None:
ChatMessage(role=Role.USER, text="Hello!"),
]
response = await client.get_response(messages=messages, chat_options=ChatOptions(max_tokens=50))
response = await client.get_response(messages=messages, options={"max_tokens": 50})
assert response is not None
assert len(response.messages) > 0
@@ -830,7 +830,7 @@ async def test_anthropic_client_integration_temperature_control() -> None:
response = await client.get_response(
messages=messages,
chat_options=ChatOptions(max_tokens=20, temperature=0.0),
options={"max_tokens": 20, "temperature": 0.0},
)
assert response is not None