diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 92a5934f0e..bd5768b968 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -60,6 +60,8 @@ jobs: OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} diff --git a/python/packages/anthropic/LICENSE b/python/packages/anthropic/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/anthropic/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/anthropic/README.md b/python/packages/anthropic/README.md new file mode 100644 index 0000000000..f8c8af674f --- /dev/null +++ b/python/packages/anthropic/README.md @@ -0,0 +1,18 @@ +# Get Started with Microsoft Agent Framework Anthropic + +Please install this package via pip: + +```bash +pip install agent-framework-anthropic --pre +``` + +## Anthropic Integration + +The Anthropic integration enables communication with the Anthropic API, allowing your Agent Framework applications to leverage Anthropic's capabilities. + +### Basic Usage Example + +See the [Anthropic agent examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/anthropic/) which demonstrate: + +- Connecting to a Anthropic endpoint with an agent +- Streaming and non-streaming responses diff --git a/python/packages/anthropic/agent_framework_anthropic/__init__.py b/python/packages/anthropic/agent_framework_anthropic/__init__.py new file mode 100644 index 0000000000..e81064b213 --- /dev/null +++ b/python/packages/anthropic/agent_framework_anthropic/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib.metadata + +from ._chat_client import AnthropicClient + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = [ + "AnthropicClient", + "__version__", +] diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py new file mode 100644 index 0000000000..d7b0334934 --- /dev/null +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -0,0 +1,658 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence +from typing import Any, ClassVar, Final, TypeVar + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + AIFunction, + Annotations, + BaseChatClient, + ChatMessage, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + CitationAnnotation, + Contents, + FinishReason, + FunctionCallContent, + FunctionResultContent, + HostedCodeInterpreterTool, + HostedMCPTool, + HostedWebSearchTool, + Role, + TextContent, + TextReasoningContent, + TextSpanRegion, + ToolProtocol, + UsageContent, + UsageDetails, + get_logger, + prepare_function_call_results, + use_chat_middleware, + use_function_invocation, +) +from agent_framework._pydantic import AFBaseSettings +from agent_framework.exceptions import ServiceInitializationError +from agent_framework.observability import use_observability +from anthropic import AsyncAnthropic +from anthropic.types.beta import ( + BetaContentBlock, + BetaMessage, + BetaMessageDeltaUsage, + BetaRawContentBlockDelta, + BetaRawMessageStreamEvent, + BetaTextBlock, + BetaUsage, +) +from pydantic import 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"] + +ROLE_MAP: dict[Role, str] = { + Role.USER: "user", + Role.ASSISTANT: "assistant", + Role.SYSTEM: "user", + Role.TOOL: "user", +} + +FINISH_REASON_MAP: dict[str, FinishReason] = { + "stop_sequence": FinishReason.STOP, + "max_tokens": FinishReason.LENGTH, + "tool_use": FinishReason.TOOL_CALLS, + "end_turn": FinishReason.STOP, + "refusal": FinishReason.CONTENT_FILTER, + "pause_turn": FinishReason.STOP, +} + + +class AnthropicSettings(AFBaseSettings): + """Anthropic Project settings. + + The settings are first loaded from environment variables with the prefix 'ANTHROPIC_'. + If the environment variables are not found, the settings can be loaded from a .env file + with the encoding 'utf-8'. If the settings are not found in the .env file, the settings + are ignored; however, validation will fail alerting that the settings are missing. + + Keyword Args: + api_key: The Anthropic API key. + chat_model_id: The Anthropic chat model ID. + env_file_path: If provided, the .env settings are read from this file path location. + env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. + + Examples: + .. code-block:: python + + from agent_framework.anthropic import AnthropicSettings + + # Using environment variables + # Set ANTHROPIC_API_KEY=your_anthropic_api_key + # ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929 + + # Or passing parameters directly + settings = AnthropicSettings(chat_model_id="claude-sonnet-4-5-20250929") + + # Or loading from a .env file + settings = AnthropicSettings(env_file_path="path/to/.env") + """ + + env_prefix: ClassVar[str] = "ANTHROPIC_" + + api_key: SecretStr | None = None + chat_model_id: str | None = None + + +TAnthropicClient = TypeVar("TAnthropicClient", bound="AnthropicClient") + + +@use_function_invocation +@use_observability +@use_chat_middleware +class AnthropicClient(BaseChatClient): + """Anthropic Chat client.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "anthropic" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + api_key: str | None = None, + model_id: str | None = None, + anthropic_client: AsyncAnthropic | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize an Anthropic Agent client. + + Keyword Args: + api_key: The Anthropic API key to use for authentication. + model_id: The ID of the model to use. + anthropic_client: An existing Anthropic client to use. If not provided, one will be created. + This can be used to further configure the client before passing it in. + For instance if you need to set a different base_url for testing or private deployments. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + kwargs: Additional keyword arguments passed to the parent class. + + Examples: + .. code-block:: python + + from agent_framework.anthropic import AnthropicClient + from azure.identity.aio import DefaultAzureCredential + + # Using environment variables + # Set ANTHROPIC_API_KEY=your_anthropic_api_key + # ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929 + + # Or passing parameters directly + client = AnthropicClient( + model_id="claude-sonnet-4-5-20250929", + api_key="your_anthropic_api_key", + ) + + # Or loading from a .env file + client = AnthropicClient(env_file_path="path/to/.env") + + # Or passing in an existing client + from anthropic import AsyncAnthropic + + anthropic_client = AsyncAnthropic( + api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com" + ) + client = AnthropicClient( + model_id="claude-sonnet-4-5-20250929", + anthropic_client=anthropic_client, + ) + + """ + try: + anthropic_settings = AnthropicSettings( + api_key=api_key, # type: ignore[arg-type] + chat_model_id=model_id, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + except ValidationError as ex: + raise ServiceInitializationError("Failed to create Anthropic settings.", ex) from ex + + if anthropic_client is None: + if not anthropic_settings.api_key: + raise ServiceInitializationError( + "Anthropic API key is required. Set via 'api_key' parameter " + "or 'ANTHROPIC_API_KEY' environment variable." + ) + + anthropic_client = AsyncAnthropic( + api_key=anthropic_settings.api_key.get_secret_value(), + default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + ) + + # Initialize parent + super().__init__(**kwargs) + + # Initialize instance variables + self.anthropic_client = anthropic_client + self.model_id = anthropic_settings.chat_model_id + # streaming requires tracking the last function call ID and name + self._last_call_id_name: tuple[str, str] | None = None + + # region Get response methods + + async def _inner_get_response( + self, + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> ChatResponse: + # Extract necessary state from messages and options + run_options = self._create_run_options(messages, chat_options, **kwargs) + message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) + return self._process_message(message) + + async def _inner_get_streaming_response( + self, + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + # Extract necessary state from messages and options + run_options = self._create_run_options(messages, chat_options, **kwargs) + async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True): + parsed_chunk = self._process_stream_event(chunk) + if parsed_chunk: + yield parsed_chunk + + # region Create Run Options and Helpers + + def _create_run_options( + self, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> dict[str, Any]: + """Create run options for the Anthropic client based on messages and chat options. + + Args: + messages: The list of chat messages. + chat_options: The chat options. + kwargs: Additional keyword arguments. + + Returns: + A dictionary of run options for the Anthropic client. + """ + run_options: dict[str, Any] = { + "model": chat_options.model_id or self.model_id, + "messages": self._convert_messages_to_anthropic_format(messages), + "max_tokens": chat_options.max_tokens or ANTHROPIC_DEFAULT_MAX_TOKENS, + "extra_headers": {"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + "betas": BETA_FLAGS, + } + + # Add any additional options from chat_options or kwargs + if chat_options.temperature is not None: + run_options["temperature"] = chat_options.temperature + if chat_options.top_p is not None: + run_options["top_p"] = chat_options.top_p + if chat_options.stop is not None: + run_options["stop_sequences"] = chat_options.stop + if messages and isinstance(messages[0], ChatMessage) and messages[0].role == Role.SYSTEM: + # first system message is passed as instructions + run_options["system"] = messages[0].text + if chat_options.tool_choice is not None: + match ( + chat_options.tool_choice if isinstance(chat_options.tool_choice, str) else chat_options.tool_choice.mode + ): + case "auto": + run_options["tool_choice"] = {"type": "auto"} + if chat_options.allow_multiple_tool_calls is not None: + run_options["tool_choice"][ # type:ignore[reportArgumentType] + "disable_parallel_tool_use" + ] = not chat_options.allow_multiple_tool_calls + case "required": + if chat_options.tool_choice.required_function_name: + run_options["tool_choice"] = { + "type": "tool", + "name": chat_options.tool_choice.required_function_name, + } + if chat_options.allow_multiple_tool_calls is not None: + run_options["tool_choice"][ # type:ignore[reportArgumentType] + "disable_parallel_tool_use" + ] = not chat_options.allow_multiple_tool_calls + else: + run_options["tool_choice"] = {"type": "any"} + if chat_options.allow_multiple_tool_calls is not None: + run_options["tool_choice"][ # type:ignore[reportArgumentType] + "disable_parallel_tool_use" + ] = not chat_options.allow_multiple_tool_calls + case "none": + run_options["tool_choice"] = {"type": "none"} + case _: + logger.debug(f"Ignoring unsupported tool choice mode: {chat_options.tool_choice.mode} for now") + if tools_and_mcp := self._convert_tools_to_anthropic_format(chat_options.tools): + run_options.update(tools_and_mcp) + if chat_options.additional_properties: + run_options.update(chat_options.additional_properties) + run_options.update(kwargs) + return run_options + + def _convert_messages_to_anthropic_format(self, messages: MutableSequence[ChatMessage]) -> list[dict[str, Any]]: + """Convert a list of ChatMessages to the format expected by the Anthropic client. + + This skips the first message if it is a system message, + as Anthropic expects system instructions as a separate parameter. + """ + # first system message is passed as instructions + if messages and isinstance(messages[0], ChatMessage) and messages[0].role == Role.SYSTEM: + return [self._convert_message_to_anthropic_format(msg) for msg in messages[1:]] + return [self._convert_message_to_anthropic_format(msg) for msg in messages] + + def _convert_message_to_anthropic_format(self, message: ChatMessage) -> dict[str, Any]: + """Convert a ChatMessage to the format expected by the Anthropic client. + + Args: + message: The ChatMessage to convert. + + Returns: + A dictionary representing the message in Anthropic format. + """ + a_content: list[dict[str, Any]] = [] + for content in message.contents: + match content.type: + case "text": + a_content.append({"type": "text", "text": content.text}) + case "data": + if content.has_top_level_media_type("image"): + a_content.append({ + "type": "image", + "source": {"data": content.uri, "media_type": content.media_type}, + }) + case "uri": + if content.has_top_level_media_type("image"): + a_content.append({"type": "image", "source": {"type": "url", "url": content.uri}}) + case "function_call": + a_content.append({ + "type": "tool_use", + "id": content.call_id, + "name": content.name, + "input": content.parse_arguments(), + }) + case "function_result": + a_content.append({ + "type": "tool_result", + "tool_use_id": content.call_id, + "content": prepare_function_call_results(content.result), + "is_error": content.exception is not None, + }) + case "text_reasoning": + a_content.append({"type": "thinking", "thinking": content.text}) + case _: + logger.debug(f"Ignoring unsupported content type: {content.type} for now") + + return { + "role": ROLE_MAP.get(message.role, "user"), + "content": a_content, + } + + def _convert_tools_to_anthropic_format( + self, tools: list[ToolProtocol | MutableMapping[str, Any]] | None + ) -> dict[str, Any] | None: + if not tools: + return None + tool_list: list[MutableMapping[str, Any]] = [] + mcp_server_list: list[MutableMapping[str, Any]] = [] + for tool in tools: + match tool: + case MutableMapping(): + tool_list.append(tool) + case AIFunction(): + tool_list.append({ + "type": "custom", + "name": tool.name, + "description": tool.description, + "input_schema": tool.parameters(), + }) + case HostedWebSearchTool(): + search_tool: dict[str, Any] = { + "type": "web_search_20250305", + "name": "web_search", + } + if tool.additional_properties: + search_tool.update(tool.additional_properties) + tool_list.append(search_tool) + case HostedCodeInterpreterTool(): + code_tool: dict[str, Any] = { + "type": "code_execution_20250825", + "name": "code_interpreter", + } + tool_list.append(code_tool) + case HostedMCPTool(): + server_def: dict[str, Any] = { + "type": "url", + "name": tool.name, + "url": str(tool.url), + } + if tool.allowed_tools: + server_def["tool_configuration"] = {"allowed_tools": list(tool.allowed_tools)} + if tool.headers and (auth := tool.headers.get("authorization")): + server_def["authorization_token"] = auth + mcp_server_list.append(server_def) + case _: + logger.debug(f"Ignoring unsupported tool type: {type(tool)} for now") + + all_tools: dict[str, list[MutableMapping[str, Any]]] = {} + if tool_list: + all_tools["tools"] = tool_list + if mcp_server_list: + all_tools["mcp_servers"] = mcp_server_list + return all_tools + + # region Response Processing Methods + + def _process_message(self, message: BetaMessage) -> ChatResponse: + """Process the response from the Anthropic client. + + Args: + message: The message returned by the Anthropic client. + + Returns: + A ChatResponse object containing the processed response. + """ + return ChatResponse( + response_id=message.id, + messages=[ + ChatMessage( + role=Role.ASSISTANT, + contents=self._parse_message_contents(message.content), + raw_representation=message, + ) + ], + usage_details=self._parse_message_usage(message.usage), + model_id=message.model, + finish_reason=FINISH_REASON_MAP.get(message.stop_reason) if message.stop_reason else None, + raw_response=message, + ) + + def _process_stream_event(self, event: BetaRawMessageStreamEvent) -> ChatResponseUpdate | None: + """Process a streaming event from the Anthropic client. + + Args: + event: The streaming event returned by the Anthropic client. + + Returns: + A ChatResponseUpdate object containing the processed update. + """ + match event.type: + case "message_start": + usage_details: list[UsageContent] = [] + if event.message.usage and (details := self._parse_message_usage(event.message.usage)): + usage_details.append(UsageContent(details=details)) + + return ChatResponseUpdate( + response_id=event.message.id, + contents=[*self._parse_message_contents(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 + else None, + raw_response=event, + ) + case "message_delta": + usage = self._parse_message_usage(event.usage) + return ChatResponseUpdate( + contents=[UsageContent(details=usage, raw_representation=event.usage)] if usage else [], + raw_response=event, + ) + case "message_stop": + logger.debug("Received message_stop event; no content to process.") + case "content_block_start": + contents = self._parse_message_contents([event.content_block]) + return ChatResponseUpdate( + contents=contents, + raw_response=event, + ) + case "content_block_delta": + contents = self._parse_message_contents([event.delta]) + return ChatResponseUpdate( + contents=contents, + raw_response=event, + ) + case "content_block_stop": + logger.debug("Received content_block_stop event; no content to process.") + case _: + logger.debug(f"Ignoring unsupported event type: {event.type}") + return None + + def _parse_message_usage(self, usage: BetaUsage | BetaMessageDeltaUsage | None) -> UsageDetails | None: + """Parse usage details from the Anthropic message usage.""" + if not usage: + return None + usage_details = UsageDetails(output_token_count=usage.output_tokens) + if usage.input_tokens is not None: + usage_details.input_token_count = usage.input_tokens + if usage.cache_creation_input_tokens is not None: + usage_details.additional_counts["anthropic.cache_creation_input_tokens"] = usage.cache_creation_input_tokens + if usage.cache_read_input_tokens is not None: + usage_details.additional_counts["anthropic.cache_read_input_tokens"] = usage.cache_read_input_tokens + return usage_details + + def _parse_message_contents( + self, content: Sequence[BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock] + ) -> list[Contents]: + """Parse contents from the Anthropic message.""" + contents: list[Contents] = [] + for content_block in content: + match content_block.type: + case "text" | "text_delta": + contents.append( + TextContent( + text=content_block.text, + raw_representation=content_block, + annotations=self._parse_citations(content_block), + ) + ) + case "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, + ) + ) + case "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, + ) + ) + case "mcp_tool_result": + call_id, name = self._last_call_id_name or (None, None) + contents.append( + FunctionResultContent( + call_id=content_block.tool_use_id, + name=name if name and call_id == content_block.tool_use_id else "mcp_tool", + result=self._parse_message_contents(content_block.content) + if isinstance(content_block.content, list) + else content_block.content, + raw_representation=content_block, + ) + ) + case "web_search_tool_result" | "web_fetch_tool_result": + call_id, name = self._last_call_id_name or (None, None) + contents.append( + FunctionResultContent( + call_id=content_block.tool_use_id, + name=name if name and call_id == content_block.tool_use_id else "web_tool", + result=content_block.content, + raw_representation=content_block, + ) + ) + case ( + "code_execution_tool_result" + | "bash_code_execution_tool_result" + | "text_editor_code_execution_tool_result" + ): + call_id, name = self._last_call_id_name or (None, None) + contents.append( + FunctionResultContent( + call_id=content_block.tool_use_id, + name=name if name and call_id == content_block.tool_use_id else "code_execution_tool", + result=content_block.content, + raw_representation=content_block, + ) + ) + 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, + ) + ) + case "thinking" | "thinking_delta": + 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 + + def _parse_citations( + self, content_block: BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock + ) -> list[Annotations] | None: + content_citations = getattr(content_block, "citations", None) + if not content_citations: + return None + annotations: list[Annotations] = [] + for citation in content_citations: + cit = CitationAnnotation(raw_representation=citation) + match citation.type: + case "char_location": + cit.title = citation.title + cit.snippet = citation.cited_text + if citation.file_id: + cit.file_id = citation.file_id + 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) + ) + case "page_location": + cit.title = citation.document_title + cit.snippet = citation.cited_text + if citation.file_id: + cit.file_id = citation.file_id + if not cit.annotated_regions: + cit.annotated_regions = [] + cit.annotated_regions.append( + TextSpanRegion( + start_index=citation.start_page_number, + end_index=citation.end_page_number, + ) + ) + case "content_block_location": + cit.title = citation.document_title + cit.snippet = citation.cited_text + if citation.file_id: + cit.file_id = citation.file_id + 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) + ) + case "web_search_result_location": + cit.title = citation.title + cit.snippet = citation.cited_text + cit.url = citation.url + case "search_result_location": + cit.title = citation.title + cit.snippet = citation.cited_text + cit.url = citation.source + 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) + ) + case _: + logger.debug(f"Unknown citation type encountered: {citation.type}") + annotations.append(cit) + return annotations or None + + def service_url(self) -> str: + """Get the service URL for the chat client. + + Returns: + The service URL for the chat client, or None if not set. + """ + return str(self.anthropic_client.base_url) diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml new file mode 100644 index 0000000000..4e620c7595 --- /dev/null +++ b/python/packages/anthropic/pyproject.toml @@ -0,0 +1,88 @@ +[project] +name = "agent-framework-anthropic" +description = "Anthropic integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0b251028" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core", + "anthropic>=0.70.0,<1", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [ + "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" +] +timeout = 120 + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_anthropic"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" +[tool.poe.tasks] +mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic" +test = "pytest --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered tests" + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/anthropic/tests/conftest.py b/python/packages/anthropic/tests/conftest.py new file mode 100644 index 0000000000..a2313f4d39 --- /dev/null +++ b/python/packages/anthropic/tests/conftest.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft. All rights reserved. +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from pytest import fixture + + +@fixture +def exclude_list(request: Any) -> list[str]: + """Fixture that returns a list of environment variables to exclude.""" + return request.param if hasattr(request, "param") else [] + + +@fixture +def override_env_param_dict(request: Any) -> dict[str, str]: + """Fixture that returns a dict of environment variables to override.""" + return request.param if hasattr(request, "param") else {} + + +@fixture +def anthropic_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore + """Fixture to set environment variables for AnthropicSettings.""" + if exclude_list is None: + exclude_list = [] + + if override_env_param_dict is None: + override_env_param_dict = {} + + env_vars = { + "ANTHROPIC_API_KEY": "test-api-key-12345", + "ANTHROPIC_CHAT_MODEL_ID": "claude-3-5-sonnet-20241022", + } + + env_vars.update(override_env_param_dict) # type: ignore + + for key, value in env_vars.items(): + if key in exclude_list: + monkeypatch.delenv(key, raising=False) # type: ignore + continue + monkeypatch.setenv(key, value) # type: ignore + + return env_vars + + +@fixture +def mock_anthropic_client() -> MagicMock: + """Fixture that provides a mock AsyncAnthropic client.""" + mock_client = MagicMock() + mock_client.base_url = "https://api.anthropic.com" + + # Mock beta.messages property + mock_client.beta = MagicMock() + mock_client.beta.messages = MagicMock() + mock_client.beta.messages.create = AsyncMock() + + return mock_client diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py new file mode 100644 index 0000000000..deff519594 --- /dev/null +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -0,0 +1,777 @@ +# Copyright (c) Microsoft. All rights reserved. +import os +from typing import Annotated +from unittest.mock import MagicMock, patch + +import pytest +from agent_framework import ( + ChatClientProtocol, + ChatMessage, + ChatOptions, + ChatResponseUpdate, + FinishReason, + FunctionCallContent, + FunctionResultContent, + HostedCodeInterpreterTool, + HostedMCPTool, + HostedWebSearchTool, + Role, + TextContent, + TextReasoningContent, + ai_function, +) +from agent_framework.exceptions import ServiceInitializationError +from anthropic.types.beta import ( + BetaMessage, + BetaTextBlock, + BetaToolUseBlock, + BetaUsage, +) +from pydantic import Field, ValidationError + +from agent_framework_anthropic import AnthropicClient +from agent_framework_anthropic._chat_client import AnthropicSettings + +skip_if_anthropic_integration_tests_disabled = pytest.mark.skipif( + os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true" + or os.getenv("ANTHROPIC_API_KEY", "") in ("", "test-api-key-12345"), + reason="No real ANTHROPIC_API_KEY provided; skipping integration tests." + if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true" + else "Integration tests are disabled.", +) + + +def create_test_anthropic_client( + mock_anthropic_client: MagicMock, + model_id: str | None = None, + anthropic_settings: AnthropicSettings | None = None, +) -> AnthropicClient: + """Helper function to create AnthropicClient instances for testing, bypassing normal validation.""" + if anthropic_settings is None: + anthropic_settings = AnthropicSettings(api_key="test-api-key-12345", chat_model_id="claude-3-5-sonnet-20241022") + + # Create client instance directly + client = object.__new__(AnthropicClient) + + # Set attributes directly + client.anthropic_client = mock_anthropic_client + client.model_id = model_id or anthropic_settings.chat_model_id + client._last_call_id_name = None + client.additional_properties = {} + client.middleware = None + + return client + + +# Settings Tests + + +def test_anthropic_settings_init(anthropic_unit_test_env: dict[str, str]) -> None: + """Test AnthropicSettings initialization.""" + settings = AnthropicSettings() + + assert settings.api_key is not None + assert settings.api_key.get_secret_value() == anthropic_unit_test_env["ANTHROPIC_API_KEY"] + assert settings.chat_model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + + +def test_anthropic_settings_init_with_explicit_values() -> None: + """Test AnthropicSettings initialization with explicit values.""" + settings = AnthropicSettings( + api_key="custom-api-key", + chat_model_id="claude-3-opus-20240229", + ) + + assert settings.api_key is not None + assert settings.api_key.get_secret_value() == "custom-api-key" + assert settings.chat_model_id == "claude-3-opus-20240229" + + +@pytest.mark.parametrize("exclude_list", [["ANTHROPIC_API_KEY"]], indirect=True) +def test_anthropic_settings_missing_api_key(anthropic_unit_test_env: dict[str, str]) -> None: + """Test AnthropicSettings when API key is missing.""" + settings = AnthropicSettings() + assert settings.api_key is None + assert settings.chat_model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + + +# Client Initialization Tests + + +def test_anthropic_client_init_with_client(mock_anthropic_client: MagicMock) -> None: + """Test AnthropicClient initialization with existing anthropic_client.""" + chat_client = create_test_anthropic_client(mock_anthropic_client, model_id="claude-3-5-sonnet-20241022") + + assert chat_client.anthropic_client is mock_anthropic_client + assert chat_client.model_id == "claude-3-5-sonnet-20241022" + assert isinstance(chat_client, ChatClientProtocol) + + +def test_anthropic_client_init_auto_create_client(anthropic_unit_test_env: dict[str, str]) -> None: + """Test AnthropicClient initialization with auto-created anthropic_client.""" + client = AnthropicClient( + api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"], + model_id=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"], + ) + + assert client.anthropic_client is not None + assert client.model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + + +def test_anthropic_client_init_missing_api_key() -> None: + """Test AnthropicClient initialization when API key is missing.""" + with patch("agent_framework_anthropic._chat_client.AnthropicSettings") as mock_settings: + mock_settings.return_value.api_key = None + mock_settings.return_value.chat_model_id = "claude-3-5-sonnet-20241022" + + with pytest.raises(ServiceInitializationError, match="Anthropic API key is required"): + AnthropicClient() + + +def test_anthropic_client_init_validation_error() -> None: + """Test that ValidationError in AnthropicSettings is properly handled.""" + with patch("agent_framework_anthropic._chat_client.AnthropicSettings") as mock_settings: + mock_settings.side_effect = ValidationError.from_exception_data("test", []) + + with pytest.raises(ServiceInitializationError, match="Failed to create Anthropic settings"): + AnthropicClient() + + +def test_anthropic_client_service_url(mock_anthropic_client: MagicMock) -> None: + """Test service_url method.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + assert chat_client.service_url() == "https://api.anthropic.com" + + +# Message Conversion Tests + + +def test_convert_message_to_anthropic_format_text(mock_anthropic_client: MagicMock) -> None: + """Test converting text message to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + message = ChatMessage(role=Role.USER, text="Hello, world!") + + result = chat_client._convert_message_to_anthropic_format(message) + + assert result["role"] == "user" + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "text" + assert result["content"][0]["text"] == "Hello, world!" + + +def test_convert_message_to_anthropic_format_function_call(mock_anthropic_client: MagicMock) -> None: + """Test converting function call message to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + message = ChatMessage( + role=Role.ASSISTANT, + contents=[ + FunctionCallContent( + call_id="call_123", + name="get_weather", + arguments={"location": "San Francisco"}, + ) + ], + ) + + result = chat_client._convert_message_to_anthropic_format(message) + + assert result["role"] == "assistant" + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "tool_use" + assert result["content"][0]["id"] == "call_123" + assert result["content"][0]["name"] == "get_weather" + assert result["content"][0]["input"] == {"location": "San Francisco"} + + +def test_convert_message_to_anthropic_format_function_result(mock_anthropic_client: MagicMock) -> None: + """Test converting function result message to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + message = ChatMessage( + role=Role.TOOL, + contents=[ + FunctionResultContent( + call_id="call_123", + name="get_weather", + result="Sunny, 72°F", + ) + ], + ) + + result = chat_client._convert_message_to_anthropic_format(message) + + assert result["role"] == "user" + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "tool_result" + assert result["content"][0]["tool_use_id"] == "call_123" + # The degree symbol might be escaped differently depending on JSON encoder + assert "Sunny" in result["content"][0]["content"] + assert "72" in result["content"][0]["content"] + assert result["content"][0]["is_error"] is False + + +def test_convert_message_to_anthropic_format_text_reasoning(mock_anthropic_client: MagicMock) -> None: + """Test converting text reasoning message to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + message = ChatMessage( + role=Role.ASSISTANT, + contents=[TextReasoningContent(text="Let me think about this...")], + ) + + result = chat_client._convert_message_to_anthropic_format(message) + + assert result["role"] == "assistant" + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "thinking" + assert result["content"][0]["thinking"] == "Let me think about this..." + + +def test_convert_messages_to_anthropic_format_with_system(mock_anthropic_client: MagicMock) -> None: + """Test converting messages list with system message.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + messages = [ + ChatMessage(role=Role.SYSTEM, text="You are a helpful assistant."), + ChatMessage(role=Role.USER, text="Hello!"), + ] + + result = chat_client._convert_messages_to_anthropic_format(messages) + + # System message should be skipped + assert len(result) == 1 + assert result[0]["role"] == "user" + assert result[0]["content"][0]["text"] == "Hello!" + + +def test_convert_messages_to_anthropic_format_without_system(mock_anthropic_client: MagicMock) -> None: + """Test converting messages list without system message.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + messages = [ + ChatMessage(role=Role.USER, text="Hello!"), + ChatMessage(role=Role.ASSISTANT, text="Hi there!"), + ] + + result = chat_client._convert_messages_to_anthropic_format(messages) + + assert len(result) == 2 + assert result[0]["role"] == "user" + assert result[1]["role"] == "assistant" + + +# Tool Conversion Tests + + +def test_convert_tools_to_anthropic_format_ai_function(mock_anthropic_client: MagicMock) -> None: + """Test converting AIFunction to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + @ai_function + def get_weather(location: Annotated[str, Field(description="Location to get weather for")]) -> str: + """Get weather for a location.""" + return f"Weather for {location}" + + tools = [get_weather] + + result = chat_client._convert_tools_to_anthropic_format(tools) + + assert result is not None + assert "tools" in result + assert len(result["tools"]) == 1 + assert result["tools"][0]["type"] == "custom" + assert result["tools"][0]["name"] == "get_weather" + assert "Get weather for a location" in result["tools"][0]["description"] + + +def test_convert_tools_to_anthropic_format_web_search(mock_anthropic_client: MagicMock) -> None: + """Test converting HostedWebSearchTool to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + tools = [HostedWebSearchTool()] + + result = chat_client._convert_tools_to_anthropic_format(tools) + + assert result is not None + assert "tools" in result + assert len(result["tools"]) == 1 + assert result["tools"][0]["type"] == "web_search_20250305" + assert result["tools"][0]["name"] == "web_search" + + +def test_convert_tools_to_anthropic_format_code_interpreter(mock_anthropic_client: MagicMock) -> None: + """Test converting HostedCodeInterpreterTool to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + tools = [HostedCodeInterpreterTool()] + + result = chat_client._convert_tools_to_anthropic_format(tools) + + assert result is not None + assert "tools" in result + assert len(result["tools"]) == 1 + assert result["tools"][0]["type"] == "code_execution_20250825" + assert result["tools"][0]["name"] == "code_interpreter" + + +def test_convert_tools_to_anthropic_format_mcp_tool(mock_anthropic_client: MagicMock) -> None: + """Test converting HostedMCPTool to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + tools = [HostedMCPTool(name="test-mcp", url="https://example.com/mcp")] + + result = chat_client._convert_tools_to_anthropic_format(tools) + + assert result is not None + assert "mcp_servers" in result + assert len(result["mcp_servers"]) == 1 + assert result["mcp_servers"][0]["type"] == "url" + assert result["mcp_servers"][0]["name"] == "test-mcp" + assert result["mcp_servers"][0]["url"] == "https://example.com/mcp" + + +def test_convert_tools_to_anthropic_format_mcp_with_auth(mock_anthropic_client: MagicMock) -> None: + """Test converting HostedMCPTool with authorization headers.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + tools = [ + HostedMCPTool( + name="test-mcp", + url="https://example.com/mcp", + headers={"authorization": "Bearer token123"}, + ) + ] + + result = chat_client._convert_tools_to_anthropic_format(tools) + + assert result is not None + assert "mcp_servers" in result + # The authorization header is converted to authorization_token + assert "authorization_token" in result["mcp_servers"][0] + assert result["mcp_servers"][0]["authorization_token"] == "Bearer token123" + + +def test_convert_tools_to_anthropic_format_dict_tool(mock_anthropic_client: MagicMock) -> None: + """Test converting dict tool to Anthropic format.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + tools = [{"type": "custom", "name": "custom_tool", "description": "A custom tool"}] + + result = chat_client._convert_tools_to_anthropic_format(tools) + + assert result is not None + assert "tools" in result + assert len(result["tools"]) == 1 + assert result["tools"][0]["name"] == "custom_tool" + + +def test_convert_tools_to_anthropic_format_none(mock_anthropic_client: MagicMock) -> None: + """Test converting None tools.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + result = chat_client._convert_tools_to_anthropic_format(None) + + assert result is None + + +# Run Options Tests + + +async def test_create_run_options_basic(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with basic ChatOptions.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(max_tokens=100, temperature=0.7) + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["model"] == chat_client.model_id + assert run_options["max_tokens"] == 100 + assert run_options["temperature"] == 0.7 + assert "messages" in run_options + + +async def test_create_run_options_with_system_message(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with system message.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ + ChatMessage(role=Role.SYSTEM, text="You are helpful."), + ChatMessage(role=Role.USER, text="Hello"), + ] + chat_options = ChatOptions() + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["system"] == "You are helpful." + assert len(run_options["messages"]) == 1 # System message not in messages list + + +async def test_create_run_options_with_tool_choice_auto(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with auto tool choice.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(tool_choice="auto") + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["tool_choice"]["type"] == "auto" + + +async def test_create_run_options_with_tool_choice_required(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with required tool choice.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + # For required with specific function, need to pass as dict + chat_options = ChatOptions(tool_choice={"mode": "required", "required_function_name": "get_weather"}) + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["tool_choice"]["type"] == "tool" + assert run_options["tool_choice"]["name"] == "get_weather" + + +async def test_create_run_options_with_tool_choice_none(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with none tool choice.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(tool_choice="none") + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["tool_choice"]["type"] == "none" + + +async def test_create_run_options_with_tools(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with tools.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + @ai_function + def get_weather(location: str) -> str: + """Get weather for a location.""" + return f"Weather for {location}" + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(tools=[get_weather]) + + run_options = chat_client._create_run_options(messages, chat_options) + + assert "tools" in run_options + assert len(run_options["tools"]) == 1 + + +async def test_create_run_options_with_stop_sequences(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with stop sequences.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(stop=["STOP", "END"]) + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["stop_sequences"] == ["STOP", "END"] + + +async def test_create_run_options_with_top_p(mock_anthropic_client: MagicMock) -> None: + """Test _create_run_options with top_p.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(top_p=0.9) + + run_options = chat_client._create_run_options(messages, chat_options) + + assert run_options["top_p"] == 0.9 + + +# Response Processing Tests + + +def test_process_message_basic(mock_anthropic_client: MagicMock) -> None: + """Test _process_message with basic text response.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + mock_message = MagicMock(spec=BetaMessage) + mock_message.id = "msg_123" + mock_message.model = "claude-3-5-sonnet-20241022" + mock_message.content = [BetaTextBlock(type="text", text="Hello there!")] + mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5) + mock_message.stop_reason = "end_turn" + + response = chat_client._process_message(mock_message) + + assert response.response_id == "msg_123" + assert response.model_id == "claude-3-5-sonnet-20241022" + assert len(response.messages) == 1 + assert response.messages[0].role == Role.ASSISTANT + assert len(response.messages[0].contents) == 1 + assert isinstance(response.messages[0].contents[0], TextContent) + assert response.messages[0].contents[0].text == "Hello there!" + assert response.finish_reason == FinishReason.STOP + assert response.usage_details is not None + assert response.usage_details.input_token_count == 10 + assert response.usage_details.output_token_count == 5 + + +def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None: + """Test _process_message with tool use.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + mock_message = MagicMock(spec=BetaMessage) + mock_message.id = "msg_123" + mock_message.model = "claude-3-5-sonnet-20241022" + mock_message.content = [ + BetaToolUseBlock( + type="tool_use", + id="call_123", + name="get_weather", + input={"location": "San Francisco"}, + ) + ] + mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5) + mock_message.stop_reason = "tool_use" + + response = chat_client._process_message(mock_message) + + assert len(response.messages[0].contents) == 1 + assert isinstance(response.messages[0].contents[0], FunctionCallContent) + assert response.messages[0].contents[0].call_id == "call_123" + assert response.messages[0].contents[0].name == "get_weather" + assert response.finish_reason == FinishReason.TOOL_CALLS + + +def test_parse_message_usage_basic(mock_anthropic_client: MagicMock) -> None: + """Test _parse_message_usage with basic usage.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + usage = BetaUsage(input_tokens=10, output_tokens=5) + result = chat_client._parse_message_usage(usage) + + assert result is not None + assert result.input_token_count == 10 + assert result.output_token_count == 5 + + +def test_parse_message_usage_none(mock_anthropic_client: MagicMock) -> None: + """Test _parse_message_usage with None usage.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + result = chat_client._parse_message_usage(None) + + assert result is None + + +def test_parse_message_contents_text(mock_anthropic_client: MagicMock) -> None: + """Test _parse_message_contents with text content.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + content = [BetaTextBlock(type="text", text="Hello!")] + result = chat_client._parse_message_contents(content) + + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "Hello!" + + +def test_parse_message_contents_tool_use(mock_anthropic_client: MagicMock) -> None: + """Test _parse_message_contents with tool use.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + content = [ + BetaToolUseBlock( + type="tool_use", + id="call_123", + name="get_weather", + input={"location": "SF"}, + ) + ] + result = chat_client._parse_message_contents(content) + + assert len(result) == 1 + assert isinstance(result[0], FunctionCallContent) + assert result[0].call_id == "call_123" + assert result[0].name == "get_weather" + + +# Stream Processing Tests + + +def test_process_stream_event_simple(mock_anthropic_client: MagicMock) -> None: + """Test _process_stream_event with simple mock event.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + # Test with a basic mock event - the actual implementation will handle real events + mock_event = MagicMock() + mock_event.type = "message_stop" + + result = chat_client._process_stream_event(mock_event) + + # message_stop events return None + assert result is None + + +async def test_inner_get_response(mock_anthropic_client: MagicMock) -> None: + """Test _inner_get_response method.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + # Create a mock message response + mock_message = MagicMock(spec=BetaMessage) + mock_message.id = "msg_test" + mock_message.model = "claude-3-5-sonnet-20241022" + mock_message.content = [BetaTextBlock(type="text", text="Hello!")] + mock_message.usage = BetaUsage(input_tokens=5, output_tokens=3) + mock_message.stop_reason = "end_turn" + + mock_anthropic_client.beta.messages.create.return_value = mock_message + + messages = [ChatMessage(role=Role.USER, text="Hi")] + chat_options = ChatOptions(max_tokens=10) + + response = await chat_client._inner_get_response( # type: ignore[attr-defined] + messages=messages, chat_options=chat_options + ) + + assert response is not None + assert response.response_id == "msg_test" + assert len(response.messages) == 1 + + +async def test_inner_get_streaming_response(mock_anthropic_client: MagicMock) -> None: + """Test _inner_get_streaming_response method.""" + chat_client = create_test_anthropic_client(mock_anthropic_client) + + # Create mock streaming response + async def mock_stream(): + mock_event = MagicMock() + mock_event.type = "message_stop" + yield mock_event + + mock_anthropic_client.beta.messages.create.return_value = mock_stream() + + messages = [ChatMessage(role=Role.USER, text="Hi")] + chat_options = ChatOptions(max_tokens=10) + + chunks: list[ChatResponseUpdate] = [] + async for chunk in chat_client._inner_get_streaming_response( # type: ignore[attr-defined] + messages=messages, chat_options=chat_options + ): + if chunk: + chunks.append(chunk) + + # We should get at least some response (even if empty due to message_stop) + assert isinstance(chunks, list) + + +# Integration Tests + + +@ai_function +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a location.""" + return f"The weather in {location} is sunny and 72°F" + + +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_basic_chat() -> None: + """Integration test for basic chat completion.""" + client = AnthropicClient() + + 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)) + + assert response is not None + assert len(response.messages) > 0 + assert response.messages[0].role == Role.ASSISTANT + assert len(response.messages[0].text) > 0 + assert response.usage_details is not None + + +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_streaming_chat() -> None: + """Integration test for streaming chat completion.""" + client = AnthropicClient() + + 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)): + chunks.append(chunk) + + assert len(chunks) > 0 + assert any(chunk.contents for chunk in chunks) + + +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_function_calling() -> None: + """Integration test for function calling.""" + client = AnthropicClient() + + messages = [ChatMessage(role=Role.USER, text="What's the weather in San Francisco?")] + tools = [get_weather] + + response = await client.get_response( + messages=messages, + chat_options=ChatOptions(tools=tools, max_tokens=100), + ) + + assert response is not None + # Should contain function call + has_function_call = any( + isinstance(content, FunctionCallContent) for msg in response.messages for content in msg.contents + ) + assert has_function_call + + +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_with_system_message() -> None: + """Integration test with system message.""" + client = AnthropicClient() + + messages = [ + ChatMessage(role=Role.SYSTEM, text="You are a pirate. Always respond like a pirate."), + ChatMessage(role=Role.USER, text="Hello!"), + ] + + response = await client.get_response(messages=messages, chat_options=ChatOptions(max_tokens=50)) + + assert response is not None + assert len(response.messages) > 0 + + +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_temperature_control() -> None: + """Integration test with temperature control.""" + client = AnthropicClient() + + messages = [ChatMessage(role=Role.USER, text="Say hello.")] + + response = await client.get_response( + messages=messages, + chat_options=ChatOptions(max_tokens=20, temperature=0.0), + ) + + assert response is not None + assert response.messages[0].text is not None + + +@pytest.mark.flaky +@skip_if_anthropic_integration_tests_disabled +async def test_anthropic_client_integration_ordering() -> None: + """Integration test with ordering.""" + client = AnthropicClient() + + messages = [ + ChatMessage(role=Role.USER, text="Say hello."), + ChatMessage(role=Role.USER, text="Then say goodbye."), + ChatMessage(role=Role.ASSISTANT, text="Thank you for chatting!"), + ChatMessage(role=Role.ASSISTANT, text="Let me know if I can help."), + ChatMessage(role=Role.USER, text="Just testing things."), + ] + + response = await client.get_response(messages=messages) + + assert response is not None + assert response.messages[0].text is not None diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index 3218d10a97..0f35158da7 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -735,10 +735,10 @@ class AzureAIAgentClient(BaseChatClient): chat_tool_mode = chat_options.tool_choice if chat_tool_mode is None or chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none": chat_options.tools = None - chat_options.tool_choice = ToolMode.NONE.mode + chat_options.tool_choice = ToolMode.NONE return - chat_options.tool_choice = chat_tool_mode.mode if isinstance(chat_tool_mode, ToolMode) else chat_tool_mode + chat_options.tool_choice = chat_tool_mode async def _create_run_options( self, diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 0b36b486c8..e4b2d53cc6 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -690,12 +690,12 @@ class BaseChatClient(SerializationMixin, ABC): chat_tool_mode = chat_options.tool_choice if chat_tool_mode is None or chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none": chat_options.tools = None - chat_options.tool_choice = ToolMode.NONE.mode + chat_options.tool_choice = ToolMode.NONE return if not chat_options.tools: - chat_options.tool_choice = ToolMode.NONE.mode + chat_options.tool_choice = ToolMode.NONE else: - chat_options.tool_choice = chat_tool_mode.mode if isinstance(chat_tool_mode, ToolMode) else chat_tool_mode + chat_options.tool_choice = chat_tool_mode def service_url(self) -> str: """Get the URL of the service. diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index f1a12f813e..9f2ad10d85 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -561,7 +561,7 @@ class BaseContent(SerializationMixin): def __init__( self, *, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -651,7 +651,7 @@ class TextContent(BaseContent): *, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, **kwargs: Any, ): """Initializes a TextContent instance. @@ -793,7 +793,7 @@ class TextReasoningContent(BaseContent): *, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, **kwargs: Any, ): """Initializes a TextReasoningContent instance. @@ -936,7 +936,7 @@ class DataContent(BaseContent): self, *, uri: str, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -962,7 +962,7 @@ class DataContent(BaseContent): *, data: bytes, media_type: str, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -989,7 +989,7 @@ class DataContent(BaseContent): uri: str | None = None, data: bytes | None = None, media_type: str | None = None, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1093,7 +1093,7 @@ class UriContent(BaseContent): uri: str, media_type: str, *, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1187,7 +1187,7 @@ class ErrorContent(BaseContent): message: str | None = None, error_code: str | None = None, details: str | None = None, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1271,7 +1271,7 @@ class FunctionCallContent(BaseContent): name: str, arguments: str | dict[str, Any | None] | None = None, exception: Exception | None = None, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1380,7 +1380,7 @@ class FunctionResultContent(BaseContent): call_id: str, result: Any | None = None, exception: Exception | None = None, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1438,7 +1438,7 @@ class UsageContent(BaseContent): self, details: UsageDetails | MutableMapping[str, Any], *, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1556,7 +1556,7 @@ class BaseUserInputRequest(BaseContent): self, *, id: str, - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1610,7 +1610,7 @@ class FunctionApprovalResponseContent(BaseContent): *, id: str, function_call: FunctionCallContent | MutableMapping[str, Any], - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -1674,7 +1674,7 @@ class FunctionApprovalRequestContent(BaseContent): *, id: str, function_call: FunctionCallContent | MutableMapping[str, Any], - annotations: list[Annotations | MutableMapping[str, Any]] | None = None, + annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, **kwargs: Any, @@ -3146,7 +3146,7 @@ class ChatOptions(SerializationMixin): @classmethod def _validate_tool_mode( cls, tool_choice: ToolMode | Literal["auto", "required", "none"] | Mapping[str, Any] | None - ) -> ToolMode | str | None: + ) -> ToolMode | None: """Validates the tool_choice field to ensure it is a valid ToolMode.""" if not tool_choice: return None diff --git a/python/packages/core/agent_framework/anthropic/__init__.py b/python/packages/core/agent_framework/anthropic/__init__.py new file mode 100644 index 0000000000..bff9c278e5 --- /dev/null +++ b/python/packages/core/agent_framework/anthropic/__init__.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib +from typing import Any + +PACKAGE_NAME = "agent_framework_anthropic" +PACKAGE_EXTRA = "anthropic" +_IMPORTS = ["__version__", "AnthropicClient"] + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + try: + return getattr(importlib.import_module(PACKAGE_NAME), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`" + ) from exc + raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.") + + +def __dir__() -> list[str]: + return _IMPORTS diff --git a/python/packages/core/agent_framework/anthropic/__init__.pyi b/python/packages/core/agent_framework/anthropic/__init__.pyi new file mode 100644 index 0000000000..dead0816f9 --- /dev/null +++ b/python/packages/core/agent_framework/anthropic/__init__.pyi @@ -0,0 +1,5 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_anthropic import AnthropicClient, __version__ + +__all__ = ["AnthropicClient", "__version__"] diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index f5a57683db..239efb76e3 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -408,7 +408,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): run_options["tools"] = tool_definitions if chat_options.tool_choice == "none" or chat_options.tool_choice == "auto": - run_options["tool_choice"] = chat_options.tool_choice + run_options["tool_choice"] = chat_options.tool_choice.mode elif ( isinstance(chat_options.tool_choice, ToolMode) and chat_options.tool_choice == "required" diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index fdb6a9717f..70a37894d4 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -191,6 +191,8 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): for key, value in additional_properties.items(): if value is not None: options_dict[key] = value + if (tool_choice := options_dict.get("tool_choice")) and len(tool_choice.keys()) == 1: + options_dict["tool_choice"] = tool_choice["mode"] return options_dict def _create_chat_response(self, response: ChatCompletion, chat_options: ChatOptions) -> "ChatResponse": diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index ff3871f13e..0d422f33bc 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -345,6 +345,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): options_dict[key] = value if "store" not in options_dict: options_dict["store"] = False + if (tool_choice := options_dict.get("tool_choice")) and len(tool_choice.keys()) == 1: + options_dict["tool_choice"] = tool_choice["mode"] return options_dict def _prepare_chat_messages_for_request(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]: diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index f5b2c23e7d..71855049a4 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -45,6 +45,8 @@ all = [ "agent-framework-mem0", "agent-framework-redis", "agent-framework-devui", + "agent-framework-purview", + "agent-framework-anthropic", ] [tool.uv] diff --git a/python/pyproject.toml b/python/pyproject.toml index c0522cd5bd..6280b3d37b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -24,13 +24,14 @@ classifiers = [ dependencies = [ "agent-framework-core", "agent-framework-a2a", + "agent-framework-anthropic", "agent-framework-azure-ai", "agent-framework-copilotstudio", + "agent-framework-devui", "agent-framework-lab", "agent-framework-mem0", - "agent-framework-redis", - "agent-framework-devui", "agent-framework-purview", + "agent-framework-redis", ] [dependency-groups] @@ -94,6 +95,7 @@ agent-framework-mem0 = { workspace = true } agent-framework-redis = { workspace = true } agent-framework-devui = { workspace = true } agent-framework-purview = { workspace = true } +agent-framework-anthropic = { workspace = true } [tool.ruff] line-length = 120 diff --git a/python/samples/README.md b/python/samples/README.md index 04f7067e1f..f8602b3385 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -14,7 +14,8 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen | File | Description | |------|-------------| -| [`getting_started/agents/anthropic/anthropic_with_openai_chat_client.py`](./getting_started/agents/anthropic/anthropic_with_openai_chat_client.py) | Anthropic with OpenAI Chat Client Example | +| [`getting_started/agents/anthropic/anthropic_basic.py`](./getting_started/agents/anthropic/anthropic_basic.py) | Agent with Anthropic Client | +| [`getting_started/agents/anthropic/anthropic_advanced.py`](./getting_started/agents/anthropic/anthropic_advanced.py) | Advanced sample with `thinking` and hosted tools. | ### Azure AI diff --git a/python/samples/getting_started/agents/anthropic/README.md b/python/samples/getting_started/agents/anthropic/README.md index be8944ae23..c0d15c3e02 100644 --- a/python/samples/getting_started/agents/anthropic/README.md +++ b/python/samples/getting_started/agents/anthropic/README.md @@ -6,12 +6,12 @@ This folder contains examples demonstrating how to use Anthropic's Claude models | File | Description | |------|-------------| -| [`anthropic_with_openai_chat_client.py`](anthropic_with_openai_chat_client.py) | Demonstrates how to configure OpenAI Chat Client to use Anthropic's Claude models. Shows both streaming and non-streaming responses with tool calling capabilities. | +| [`anthropic_basic.py`](anthropic_basic.py) | Demonstrates how to setup a simple agent using the AnthropicClient, with both streaming and non-streaming responses. | +| [`anthropic_advanced.py`](anthropic_advanced.py) | Shows advanced usage of the AnthropicClient, including hosted tools and `thinking`. | ## Environment Variables Set the following environment variables before running the examples: - `ANTHROPIC_API_KEY`: Your Anthropic API key (get one from [Anthropic Console](https://console.anthropic.com/)) -- `ANTHROPIC_MODEL`: The Claude model to use (e.g., `claude-3-5-sonnet-20241022`, `claude-3-haiku-20240307`) - +- `ANTHROPIC_MODEL`: The Claude model to use (e.g., `claude-haiku-4-5`, `claude-sonnet-4-5-20250929`) diff --git a/python/samples/getting_started/agents/anthropic/anthropic_advanced.py b/python/samples/getting_started/agents/anthropic/anthropic_advanced.py new file mode 100644 index 0000000000..a7f4ae2656 --- /dev/null +++ b/python/samples/getting_started/agents/anthropic/anthropic_advanced.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import HostedMCPTool, HostedWebSearchTool, TextReasoningContent, UsageContent +from agent_framework.anthropic import AnthropicClient + +""" +Anthropic Chat Agent Example + +This sample demonstrates using Anthropic with: +- Setting up an Anthropic-based agent with hosted tools. +- Using the `thinking` feature. +- Displaying both thinking and usage information during streaming responses. +""" + + +async def streaming_example() -> None: + """Example of streaming response (get results as they are generated).""" + agent = AnthropicClient().create_agent( + name="DocsAgent", + instructions="You are a helpful agent for both Microsoft docs questions and general questions.", + tools=[ + HostedMCPTool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + ), + HostedWebSearchTool(), + ], + # anthropic needs a value for the max_tokens parameter + # we set it to 1024, but you can override like this: + max_tokens=20000, + additional_chat_options={"thinking": {"type": "enabled", "budget_tokens": 10000}}, + ) + + query = "Can you compare Python decorators with C# attributes?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run_stream(query): + for content in chunk.contents: + if isinstance(content, TextReasoningContent): + print(f"\033[32m{content.text}\033[0m", end="", flush=True) + if isinstance(content, UsageContent): + print(f"\n\033[34m[Usage so far: {content.details}]\033[0m\n", end="", flush=True) + if chunk.text: + print(chunk.text, end="", flush=True) + + print("\n") + + +async def main() -> None: + print("=== Anthropic Example ===") + + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/anthropic/anthropic_with_openai_chat_client.py b/python/samples/getting_started/agents/anthropic/anthropic_basic.py similarity index 67% rename from python/samples/getting_started/agents/anthropic/anthropic_with_openai_chat_client.py rename to python/samples/getting_started/agents/anthropic/anthropic_basic.py index 5f59b6c8b4..9011483136 100644 --- a/python/samples/getting_started/agents/anthropic/anthropic_with_openai_chat_client.py +++ b/python/samples/getting_started/agents/anthropic/anthropic_basic.py @@ -1,17 +1,15 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -import os from random import randint from typing import Annotated -from agent_framework.openai import OpenAIChatClient +from agent_framework.anthropic import AnthropicClient """ -Anthropic with OpenAI Chat Client Example +Anthropic Chat Agent Example -This sample demonstrates using Anthropic models through OpenAI Chat Client by -configuring the base URL to point to Anthropic's API for cross-provider compatibility. +This sample demonstrates using Anthropic with an agent and a single custom tool. """ @@ -27,10 +25,7 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - agent = OpenAIChatClient( - api_key=os.getenv("ANTHROPIC_API_KEY"), - base_url="https://api.anthropic.com/v1/", - model_id=os.getenv("ANTHROPIC_MODEL"), + agent = AnthropicClient( ).create_agent( name="WeatherAgent", instructions="You are a helpful weather agent.", @@ -47,17 +42,14 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - agent = OpenAIChatClient( - api_key=os.getenv("ANTHROPIC_API_KEY"), - base_url="https://api.anthropic.com/v1/", - model_id=os.getenv("ANTHROPIC_MODEL"), + agent = AnthropicClient( ).create_agent( name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) - query = "What's the weather like in Portland?" + query = "What's the weather like in Portland and in Paris?" print(f"User: {query}") print("Agent: ", end="", flush=True) async for chunk in agent.run_stream(query): @@ -67,10 +59,10 @@ async def streaming_example() -> None: async def main() -> None: - print("=== Anthropic with OpenAI Chat Client Agent Example ===") + print("=== Anthropic Example ===") - await non_streaming_example() await streaming_example() + await non_streaming_example() if __name__ == "__main__": diff --git a/python/uv.lock b/python/uv.lock index 0e11d3bd11..c19ba2cc89 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -31,6 +31,7 @@ supported-markers = [ members = [ "agent-framework", "agent-framework-a2a", + "agent-framework-anthropic", "agent-framework-azure-ai", "agent-framework-copilotstudio", "agent-framework-core", @@ -76,6 +77,7 @@ version = "1.0.0b251028" source = { virtual = "." } dependencies = [ { name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-azure-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -117,6 +119,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "agent-framework-a2a", editable = "packages/a2a" }, + { name = "agent-framework-anthropic", editable = "packages/anthropic" }, { name = "agent-framework-azure-ai", editable = "packages/azure-ai" }, { name = "agent-framework-copilotstudio", editable = "packages/copilotstudio" }, { name = "agent-framework-core", editable = "packages/core" }, @@ -170,6 +173,21 @@ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, ] +[[package]] +name = "agent-framework-anthropic" +version = "1.0.0b251028" +source = { editable = "packages/anthropic" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "anthropic", specifier = ">=0.70.0,<1" }, +] + [[package]] name = "agent-framework-azure-ai" version = "1.0.0b251028" @@ -222,20 +240,24 @@ dependencies = [ [package.optional-dependencies] all = [ { name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-azure-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-purview", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] requires-dist = [ { name = "agent-framework-a2a", marker = "extra == 'all'", editable = "packages/a2a" }, + { name = "agent-framework-anthropic", marker = "extra == 'all'", editable = "packages/anthropic" }, { name = "agent-framework-azure-ai", marker = "extra == 'all'", editable = "packages/azure-ai" }, { name = "agent-framework-copilotstudio", marker = "extra == 'all'", editable = "packages/copilotstudio" }, { name = "agent-framework-devui", marker = "extra == 'all'", editable = "packages/devui" }, { name = "agent-framework-mem0", marker = "extra == 'all'", editable = "packages/mem0" }, + { name = "agent-framework-purview", marker = "extra == 'all'", editable = "packages/purview" }, { name = "agent-framework-redis", marker = "extra == 'all'", editable = "packages/redis" }, { name = "azure-identity", specifier = ">=1,<2" }, { name = "mcp", extras = ["ws"], specifier = ">=1.13" }, @@ -648,6 +670,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "docstring-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jiter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/07/61f3ca8e69c5dcdaec31b36b79a53ea21c5b4ca5e93c7df58c71f43bf8d8/anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a", size = 493721, upload-time = "2025-10-28T19:13:01.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/b7/160d4fb30080395b4143f1d1a4f6c646ba9105561108d2a434b606c03579/anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d", size = 357464, upload-time = "2025-10-28T19:13:00.215Z" }, +] + [[package]] name = "anyio" version = "4.11.0" @@ -1926,11 +1967,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2025.9.0" +version = "2025.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/e0/bab50af11c2d75c9c4a2a26a5254573c0bd97cea152254401510950486fa/fsspec-2025.9.0.tar.gz", hash = "sha256:19fd429483d25d28b65ec68f9f4adc16c17ea2c7c7bf54ec61360d478fb19c19", size = 304847, upload-time = "2025-09-02T19:10:49.215Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/7f/2747c0d332b9acfa75dc84447a066fdf812b5a6b8d30472b74d309bfe8cb/fsspec-2025.10.0.tar.gz", hash = "sha256:b6789427626f068f9a83ca4e8a3cc050850b6c0f71f99ddb4f542b8266a26a59", size = 309285, upload-time = "2025-10-30T14:58:44.036Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289, upload-time = "2025-09-02T19:10:47.708Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d", size = 200966, upload-time = "2025-10-30T14:58:42.53Z" }, ] [[package]] @@ -1951,16 +1992,16 @@ wheels = [ [[package]] name = "google-auth" -version = "2.42.0" +version = "2.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyasn1-modules", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "rsa", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/75/28881e9d7de9b3d61939bc9624bd8fa594eb787a00567aba87173c790f09/google_auth-2.42.0.tar.gz", hash = "sha256:9bbbeef3442586effb124d1ca032cfb8fb7acd8754ab79b55facd2b8f3ab2802", size = 295400, upload-time = "2025-10-28T17:38:08.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/6b/22a77135757c3a7854c9f008ffed6bf4e8851616d77faf13147e9ab5aae6/google_auth-2.42.1.tar.gz", hash = "sha256:30178b7a21aa50bffbdc1ffcb34ff770a2f65c712170ecd5446c4bef4dc2b94e", size = 295541, upload-time = "2025-10-30T16:42:19.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/24/ec82aee6ba1a076288818fe5cc5125f4d93fffdc68bb7b381c68286c8aaa/google_auth-2.42.0-py2.py3-none-any.whl", hash = "sha256:f8f944bcb9723339b0ef58a73840f3c61bc91b69bf7368464906120b55804473", size = 222550, upload-time = "2025-10-28T17:38:05.496Z" }, + { url = "https://files.pythonhosted.org/packages/92/05/adeb6c495aec4f9d93f9e2fc29eeef6e14d452bba11d15bdb874ce1d5b10/google_auth-2.42.1-py2.py3-none-any.whl", hash = "sha256:eb73d71c91fc95dbd221a2eb87477c278a355e7367a35c0d84e6b0e5f9b4ad11", size = 222550, upload-time = "2025-10-30T16:42:17.878Z" }, ] [[package]] @@ -3934,28 +3975,28 @@ wheels = [ [[package]] name = "polars" -version = "1.34.0" +version = "1.35.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/3e/35fcf5bf51404371bb172b289a5065778dc97adca4416e199c294125eb05/polars-1.34.0.tar.gz", hash = "sha256:5de5f871027db4b11bcf39215a2d6b13b4a80baf8a55c5862d4ebedfd5cd4013", size = 684309, upload-time = "2025-10-02T18:31:04.396Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/5b/3caad788d93304026cbf0ab4c37f8402058b64a2f153b9c62f8b30f5d2ee/polars-1.35.1.tar.gz", hash = "sha256:06548e6d554580151d6ca7452d74bceeec4640b5b9261836889b8e68cfd7a62e", size = 694881, upload-time = "2025-10-30T12:12:52.294Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/80/1791ac226bb989bef30fe8fde752b2021b6ec5dfd6e880262596aedf4c05/polars-1.34.0-py3-none-any.whl", hash = "sha256:40d2f357b4d9e447ad28bd2c9923e4318791a7c18eb68f31f1fbf11180f41391", size = 772686, upload-time = "2025-10-02T18:29:59.492Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4c/21a227b722534404241c2a76beceb7463469d50c775a227fc5c209eb8adc/polars-1.35.1-py3-none-any.whl", hash = "sha256:c29a933f28aa330d96a633adbd79aa5e6a6247a802a720eead9933f4613bdbf4", size = 783598, upload-time = "2025-10-30T12:11:54.668Z" }, ] [[package]] name = "polars-runtime-32" -version = "1.34.0" +version = "1.35.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/10/1189afb14cc47ed215ccf7fbd00ed21c48edfd89e51c16f8628a33ae4b1b/polars_runtime_32-1.34.0.tar.gz", hash = "sha256:ebe6f865128a0d833f53a3f6828360761ad86d1698bceb22bef9fd999500dc1c", size = 2634491, upload-time = "2025-10-02T18:31:05.502Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/3e/19c252e8eb4096300c1a36ec3e50a27e5fa9a1ccaf32d3927793c16abaee/polars_runtime_32-1.35.1.tar.gz", hash = "sha256:f6b4ec9cd58b31c87af1b8c110c9c986d82345f1d50d7f7595b5d447a19dc365", size = 2696218, upload-time = "2025-10-30T12:12:53.479Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/35/bc4f1a9dcef61845e8e4e5d2318470b002b93a3564026f0643f562761ecb/polars_runtime_32-1.34.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2878f9951e91121afe60c25433ef270b9a221e6ebf3de5f6642346b38cab3f03", size = 39655423, upload-time = "2025-10-02T18:30:02.846Z" }, - { url = "https://files.pythonhosted.org/packages/a6/bb/d655a103e75b7c81c47a3c2d276be0200c0c15cfb6fd47f17932ddcf7519/polars_runtime_32-1.34.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:fbc329c7d34a924228cc5dcdbbd4696d94411a3a5b15ad8bb868634c204e1951", size = 35986049, upload-time = "2025-10-02T18:30:05.848Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ce/11ca850b7862cb43605e5d86cdf655614376e0a059871cf8305af5406554/polars_runtime_32-1.34.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93fa51d88a2d12ea996a5747aad5647d22a86cce73c80f208e61f487b10bc448", size = 40261269, upload-time = "2025-10-02T18:30:08.48Z" }, - { url = "https://files.pythonhosted.org/packages/d8/25/77d12018c35489e19f7650b40679714a834effafc25d61e8dcee7c4fafce/polars_runtime_32-1.34.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:79e4d696392c6d8d51f4347f0b167c52eef303c9d87093c0c68e8651198735b7", size = 37049077, upload-time = "2025-10-02T18:30:11.162Z" }, - { url = "https://files.pythonhosted.org/packages/e2/75/c30049d45ea1365151f86f650ed5354124ff3209f0abe588664c8eb13a31/polars_runtime_32-1.34.0-cp39-abi3-win_amd64.whl", hash = "sha256:2501d6b29d9001ea5ea2fd9b598787e10ddf45d8c4a87c2bead75159e8a15711", size = 40105782, upload-time = "2025-10-02T18:30:14.597Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/84efa27aa3478c8670bac1a720c8b1aee5c58c9c657c980e5e5c47fde883/polars_runtime_32-1.34.0-cp39-abi3-win_arm64.whl", hash = "sha256:f9ed1765378dfe0bcd1ac5ec570dd9eab27ea728bbc980cc9a76eebc55586559", size = 35873216, upload-time = "2025-10-02T18:30:17.439Z" }, + { url = "https://files.pythonhosted.org/packages/08/2c/da339459805a26105e9d9c2f07e43ca5b8baeee55acd5457e6881487a79a/polars_runtime_32-1.35.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6f051a42f6ae2f26e3bc2cf1f170f2120602976e2a3ffb6cfba742eecc7cc620", size = 40525100, upload-time = "2025-10-30T12:11:58.098Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/a0733568b3533481924d2ce68b279ab3d7334e5fa6ed259f671f650b7c5e/polars_runtime_32-1.35.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c2232f9cf05ba59efc72d940b86c033d41fd2d70bf2742e8115ed7112a766aa9", size = 36701908, upload-time = "2025-10-30T12:12:02.166Z" }, + { url = "https://files.pythonhosted.org/packages/46/54/6c09137bef9da72fd891ba58c2962cc7c6c5cad4649c0e668d6b344a9d7b/polars_runtime_32-1.35.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42f9837348557fd674477ea40a6ac8a7e839674f6dd0a199df24be91b026024c", size = 41317692, upload-time = "2025-10-30T12:12:04.928Z" }, + { url = "https://files.pythonhosted.org/packages/22/55/81c5b266a947c339edd7fbaa9e1d9614012d02418453f48b76cc177d3dd9/polars_runtime_32-1.35.1-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:c873aeb36fed182d5ebc35ca17c7eb193fe83ae2ea551ee8523ec34776731390", size = 37853058, upload-time = "2025-10-30T12:12:08.342Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/be8b034d559eac515f52408fd6537be9bea095bc0388946a4e38910d3d50/polars_runtime_32-1.35.1-cp39-abi3-win_amd64.whl", hash = "sha256:35cde9453ca7032933f0e58e9ed4388f5a1e415dd0db2dd1e442c81d815e630c", size = 41289554, upload-time = "2025-10-30T12:12:11.104Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7f/e0111b9e2a1169ea82cde3ded9c92683e93c26dfccd72aee727996a1ac5b/polars_runtime_32-1.35.1-cp39-abi3-win_arm64.whl", hash = "sha256:fd77757a6c9eb9865c4bfb7b07e22225207c6b7da382bd0b9bd47732f637105d", size = 36958878, upload-time = "2025-10-30T12:12:15.206Z" }, ] [[package]] @@ -5588,14 +5629,14 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.0.2" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" }, ] [[package]]