diff --git a/python/.cspell.json b/python/.cspell.json index ab7106ad7b..1b21f5263d 100644 --- a/python/.cspell.json +++ b/python/.cspell.json @@ -53,6 +53,7 @@ "nopep", "NOSQL", "ollama", + "otlp", "Onnx", "onyourdatatest", "OPENAI", diff --git a/python/.env.example b/python/.env.example index e8de35394a..ecbdef60a8 100644 --- a/python/.env.example +++ b/python/.env.example @@ -1,8 +1,17 @@ +# Foundry FOUNDRY_PROJECT_ENDPOINT="" FOUNDRY_MODEL_DEPLOYMENT_NAME="" +# OpenAI OPENAI_API_KEY="" OPENAI_CHAT_MODEL_ID="" +OPENAI_RESPONSES_MODEL_ID="" +# Azure OpenAI AZURE_OPENAI_ENDPOINT="" AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="" AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME="" -OPENAI_RESPONSES_MODEL_ID="" +# Telemetry +AGENT_FRAMEWORK_MONITOR_CONNECTION_STRING="..." +AGENT_FRAMEWORK_OTLP_ENDPOINT="http://localhost:4317/" +AGENT_FRAMEWORK_ENABLE_OTEL=true +AGENT_FRAMEWORK_ENABLE_SENSITIVE_DATA=true +AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL=true diff --git a/python/packages/azure/agent_framework_azure/_chat_client.py b/python/packages/azure/agent_framework_azure/_chat_client.py index f140f281b2..11231353bf 100644 --- a/python/packages/azure/agent_framework_azure/_chat_client.py +++ b/python/packages/azure/agent_framework_azure/_chat_client.py @@ -11,9 +11,11 @@ from agent_framework import ( ChatResponseUpdate, CitationAnnotation, TextContent, + use_function_invocation, ) from agent_framework.exceptions import ServiceInitializationError from agent_framework.openai._chat_client import OpenAIBaseChatClient +from agent_framework.telemetry import use_telemetry from azure.core.credentials import TokenCredential from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI from openai.types.chat.chat_completion import Choice @@ -37,6 +39,8 @@ TChatResponse = TypeVar("TChatResponse", ChatResponse, ChatResponseUpdate) TAzureChatClient = TypeVar("TAzureChatClient", bound="AzureChatClient") +@use_function_invocation +@use_telemetry class AzureChatClient(AzureOpenAIConfigMixin, OpenAIBaseChatClient): """Azure Chat completion class.""" diff --git a/python/packages/azure/agent_framework_azure/_responses_client.py b/python/packages/azure/agent_framework_azure/_responses_client.py index de4d679e37..b1811280a6 100644 --- a/python/packages/azure/agent_framework_azure/_responses_client.py +++ b/python/packages/azure/agent_framework_azure/_responses_client.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from typing import Any, TypeVar from urllib.parse import urljoin -from agent_framework import use_tool_calling +from agent_framework import use_function_invocation from agent_framework.exceptions import ServiceInitializationError from agent_framework.openai._responses_client import OpenAIBaseResponsesClient from agent_framework.telemetry import use_telemetry @@ -22,7 +22,7 @@ TAzureResponsesClient = TypeVar("TAzureResponsesClient", bound="AzureResponsesCl @use_telemetry -@use_tool_calling +@use_function_invocation class AzureResponsesClient(AzureOpenAIConfigMixin, OpenAIBaseResponsesClient): """Azure Responses completion class.""" diff --git a/python/packages/azure/agent_framework_azure/_shared.py b/python/packages/azure/agent_framework_azure/_shared.py index 6f3a6a72fd..4addf194b2 100644 --- a/python/packages/azure/agent_framework_azure/_shared.py +++ b/python/packages/azure/agent_framework_azure/_shared.py @@ -168,7 +168,7 @@ class AzureOpenAISettings(AFBaseSettings): class AzureOpenAIConfigMixin(OpenAIBase): """Internal class for configuring a connection to an Azure OpenAI service.""" - MODEL_PROVIDER_NAME: ClassVar[str] = "azure_openai" # type: ignore[reportIncompatibleVariableOverride, misc] + OTEL_PROVIDER_NAME: ClassVar[str] = "azure_openai" # type: ignore[reportIncompatibleVariableOverride, misc] @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def __init__( diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 811daed8b8..7f375b885e 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -1,6 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -import contextlib import json import sys from collections.abc import AsyncIterable, MutableMapping, MutableSequence @@ -24,7 +23,7 @@ from agent_framework import ( UriContent, UsageContent, UsageDetails, - use_tool_calling, + use_function_invocation, ) from agent_framework._pydantic import AFBaseSettings from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException @@ -98,17 +97,17 @@ TFoundryChatClient = TypeVar("TFoundryChatClient", bound="FoundryChatClient") HEADERS = prepend_agent_framework_to_user_agent() +@use_function_invocation @use_telemetry -@use_tool_calling class FoundryChatClient(BaseChatClient): """Azure AI Foundry Chat client.""" - MODEL_PROVIDER_NAME: ClassVar[str] = "azure_ai_foundry" # type: ignore[reportIncompatibleVariableOverride, misc] + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry" # type: ignore[reportIncompatibleVariableOverride, misc] client: AIProjectClient = Field(...) credential: AsyncTokenCredential | None = Field(...) agent_id: str | None = Field(default=None) agent_name: str | None = Field(default=None) - ai_model_deployment_name: str | None = Field(default=None) + ai_model_id: str | None = Field(default=None) thread_id: str | None = Field(default=None) _should_delete_agent: bool = PrivateAttr(default=False) # Track whether we should delete the agent _should_close_client: bool = PrivateAttr(default=False) # Track whether we should close client connection @@ -140,6 +139,7 @@ class FoundryChatClient(BaseChatClient): project_endpoint: The Azure AI Foundry project endpoint URL. Used if client is not provided. model_deployment_name: The model deployment name to use for agent creation. async_credential: Azure async credential to use for authentication. + setup_tracing: Whether to setup tracing for the client. Defaults to True. 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. @@ -182,11 +182,25 @@ class FoundryChatClient(BaseChatClient): agent_id=agent_id, # type: ignore[reportCallIssue] thread_id=thread_id, # type: ignore[reportCallIssue] agent_name=foundry_settings.agent_name, # type: ignore[reportCallIssue] - ai_model_deployment_name=foundry_settings.model_deployment_name, # type: ignore[reportCallIssue] + ai_model_id=foundry_settings.model_deployment_name, # type: ignore[reportCallIssue] **kwargs, ) self._should_close_client = should_close_client + async def setup_foundry_telemetry(self, enable_live_metrics: bool = False) -> None: + """Call this method to setup tracing with Foundry. + + This will take the connection string from the project client. + It will override any connection string that is set in the environment variables. + It will disable any OTLP endpoint that might have been set. + """ + from agent_framework.telemetry import setup_telemetry + + setup_telemetry( + application_insights_connection_string=await self.client.telemetry.get_application_insights_connection_string(), # noqa: E501 + enable_live_metrics=enable_live_metrics, + ) + async def __aenter__(self) -> "Self": """Async context manager entry.""" return self @@ -241,7 +255,9 @@ class FoundryChatClient(BaseChatClient): # Get the thread ID thread_id: str | None = ( - chat_options.conversation_id if chat_options.conversation_id is not None else self.thread_id + chat_options.conversation_id + if chat_options.conversation_id is not None + else run_options.get("conversation_id", self.thread_id) ) if thread_id is None and tool_results is not None: @@ -265,12 +281,12 @@ class FoundryChatClient(BaseChatClient): """ # If no agent_id is provided, create a temporary agent if self.agent_id is None: - if not self.ai_model_deployment_name: + if not self.ai_model_id: raise ServiceInitializationError("Model deployment name is required for agent creation.") agent_name = self.agent_name args = { - "model": self.ai_model_deployment_name, + "model": self.ai_model_id, "name": agent_name, "headers": HEADERS, } @@ -323,6 +339,7 @@ class FoundryChatClient(BaseChatClient): final_thread_id = await self._prepare_thread(thread_id, thread_run, run_options) # Now create a new run and stream the results. + run_options.pop("conversation_id", None) stream = await self.client.agents.runs.stream( # type: ignore[reportUnknownMemberType] final_thread_id, agent_id=agent_id, @@ -353,21 +370,33 @@ class FoundryChatClient(BaseChatClient): self, thread_id: str | None, thread_run: ThreadRun | None, run_options: dict[str, Any] ) -> str: """Prepare the thread for a new run, creating or cleaning up as needed.""" - if thread_id is None: - # No thread ID was provided, so create a new thread. - thread = await self.client.agents.threads.create( - messages=run_options["additional_messages"], - tool_resources=run_options.get("tool_resources"), - metadata=run_options.get("metadata"), + if thread_id is not None: + if thread_run is not None: + # There was an active run; we need to cancel it before starting a new run. + await self.client.agents.runs.cancel(thread_id, thread_run.id, headers=HEADERS) + + return thread_id + + # No thread ID was provided, so create a new thread. + thread = await self.client.agents.threads.create( + tool_resources=run_options.get("tool_resources"), + metadata=run_options.get("metadata"), + headers=HEADERS, + ) + thread_id = thread.id + # workaround for: https://github.com/Azure/azure-sdk-for-python/issues/42805 + # this occurs when otel is enabled + # once fixed, in the function above, readd: + # `messages=run_options.pop("additional_messages")` + for msg in run_options.pop("additional_messages", []): + await self.client.agents.messages.create( + thread_id=thread_id, + role=msg.role, + content=msg.content, + metadata=msg.metadata, headers=HEADERS, ) - run_options["additional_messages"] = [] - return thread.id - - if thread_run is not None: - # There was an active run; we need to cancel it before starting a new run. - await self.client.agents.runs.cancel(thread_id, thread_run.id, headers=HEADERS) - + # and remove until here. return thread_id async def _process_stream_events( @@ -378,7 +407,7 @@ class FoundryChatClient(BaseChatClient): """Process events from the agent stream and yield ChatResponseUpdate objects.""" # Use 'async with' only if the stream supports async context management (main agent stream). # Tool output handlers only support async iteration, not context management. - if isinstance(stream, contextlib.AbstractAsyncContextManager): + if isinstance(stream, AsyncAgentRunStream): async with stream as response_stream: # type: ignore async for update in self._process_stream_events_from_iterator(response_stream, thread_id): yield update @@ -387,7 +416,7 @@ class FoundryChatClient(BaseChatClient): yield update async def _process_stream_events_from_iterator( - self, stream_iter: Any, thread_id: str + self, stream_iter: AsyncAgentEventHandler[Any], thread_id: str ) -> AsyncIterable[ChatResponseUpdate]: """Process events from the stream iterator and yield ChatResponseUpdate objects.""" response_id: str | None = None @@ -400,6 +429,7 @@ class FoundryChatClient(BaseChatClient): raw_representation=event_data, response_id=response_id, role=Role.ASSISTANT, + ai_model_id=event_data.model, ) elif event_type == AgentStreamEvent.THREAD_RUN_STEP_CREATED and isinstance(event_data, RunStep): response_id = event_data.run_id @@ -429,7 +459,7 @@ class FoundryChatClient(BaseChatClient): response_id=response_id, ) elif ( - event_type == AgentStreamEvent.THREAD_RUN_COMPLETED + event_type in [AgentStreamEvent.THREAD_RUN_COMPLETED, AgentStreamEvent.THREAD_RUN_STEP_COMPLETED] and isinstance(event_data, RunStep) and event_data.usage is not None ): @@ -468,16 +498,16 @@ class FoundryChatClient(BaseChatClient): """Create function call contents from a tool action event.""" contents: list[Contents] = [] - if isinstance(event_data.required_action, SubmitToolOutputsAction): + if isinstance(event_data, ThreadRun) and isinstance(event_data.required_action, SubmitToolOutputsAction): for tool_call in event_data.required_action.submit_tool_outputs.tool_calls: if isinstance(tool_call, RequiredFunctionToolCall): - call_id = json.dumps([response_id, tool_call.id]) - function_name = tool_call.function.name - function_arguments = json.loads(tool_call.function.arguments) contents.append( - FunctionCallContent(call_id=call_id, name=function_name, arguments=function_arguments) + FunctionCallContent( + call_id=f'["{response_id}", "{tool_call.id}"]', + name=tool_call.function.name, + arguments=tool_call.function.arguments, + ) ) - return contents async def _close_client_if_needed(self) -> None: @@ -632,3 +662,11 @@ class FoundryChatClient(BaseChatClient): # to update the agent name in the client. if agent_name and not self.agent_name: self.agent_name = agent_name + + 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 self.client._config.endpoint diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml index 2d12831c69..1c1f18605f 100644 --- a/python/packages/foundry/pyproject.toml +++ b/python/packages/foundry/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "agent-framework", "azure-ai-projects >= 1.0.0b11", "azure-ai-agents >= 1.1.0b1", - "aiohttp ~= 3.8" + "aiohttp ~= 3.8", ] [tool.uv] diff --git a/python/packages/foundry/tests/conftest.py b/python/packages/foundry/tests/conftest.py index cb204880ee..eb858f7a1b 100644 --- a/python/packages/foundry/tests/conftest.py +++ b/python/packages/foundry/tests/conftest.py @@ -62,6 +62,7 @@ def mock_ai_project_client() -> MagicMock: # Mock threads property mock_client.agents.threads = MagicMock() mock_client.agents.threads.create = AsyncMock() + mock_client.agents.messages.create = AsyncMock() # Mock runs property mock_client.agents.runs = MagicMock() diff --git a/python/packages/foundry/tests/test_foundry_chat_client.py b/python/packages/foundry/tests/test_foundry_chat_client.py index b77efe5954..8a7eec03b6 100644 --- a/python/packages/foundry/tests/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/test_foundry_chat_client.py @@ -25,6 +25,7 @@ from agent_framework import ( ) from agent_framework import __version__ as AF_VERSION from agent_framework.exceptions import ServiceInitializationError +from agent_framework.foundry import FoundryChatClient, FoundrySettings from azure.ai.agents.models import ( RequiredFunctionToolCall, SubmitToolOutputsAction, @@ -34,8 +35,6 @@ from azure.core.credentials_async import AsyncTokenCredential from azure.identity.aio import AzureCliCredential from pydantic import Field, ValidationError -from agent_framework_foundry import FoundryChatClient, FoundrySettings - skip_if_foundry_integration_tests_disabled = pytest.mark.skipif( os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true" or os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/"), @@ -62,8 +61,7 @@ def create_test_foundry_chat_client( thread_id=thread_id, _should_delete_agent=should_delete_agent, agent_name=foundry_settings.agent_name, # type: ignore[reportCallIssue] - ai_model_deployment_name=foundry_settings.model_deployment_name, # type: - credential=None, + ai_model_id=foundry_settings.model_deployment_name, ) diff --git a/python/packages/main/agent_framework/_agents.py b/python/packages/main/agent_framework/_agents.py index bc22654079..2b63ca4ef0 100644 --- a/python/packages/main/agent_framework/_agents.py +++ b/python/packages/main/agent_framework/_agents.py @@ -9,11 +9,12 @@ from uuid import uuid4 from pydantic import BaseModel, Field, PrivateAttr -from ._clients import ChatClientProtocol +from ._clients import BaseChatClient, ChatClientProtocol +from ._logging import get_logger from ._mcp import MCPTool from ._pydantic import AFBaseModel from ._threads import AgentThread, ChatMessageStore, deserialize_thread_state, thread_on_new_messages -from ._tools import ToolProtocol +from ._tools import FUNCTION_INVOKING_CHAT_CLIENT_MARKER, ToolProtocol from ._types import ( AgentRunResponse, AgentRunResponseUpdate, @@ -32,6 +33,8 @@ if sys.version_info >= (3, 11): else: from typing_extensions import Self # pragma: no cover +logger = get_logger("agent_framework") + TThreadType = TypeVar("TThreadType", bound="AgentThread") __all__ = ["AgentProtocol", "BaseAgent", "ChatAgent"] @@ -248,6 +251,11 @@ class ChatAgent(BaseAgent): kwargs: any additional keyword arguments. Unused, can be used by subclasses of this Agent. """ + if not hasattr(chat_client, FUNCTION_INVOKING_CHAT_CLIENT_MARKER) and isinstance(chat_client, BaseChatClient): + logger.warning( + "The provided chat client does not support function invoking, this might limit agent capabilities." + ) + kwargs.update(additional_properties or {}) # We ignore the MCP Servers here and store them separately, @@ -317,8 +325,8 @@ class ChatAgent(BaseAgent): should check if there is already a agent name defined, and if not set it to this value. """ - if hasattr(self.chat_client, "_update_agent_name") and callable(self.chat_client._update_agent_name): # type: ignore[reportAttributeAccessIssue] - self.chat_client._update_agent_name(self.name) # type: ignore[reportAttributeAccessIssue] + if hasattr(self.chat_client, "_update_agent_name") and callable(self.chat_client._update_agent_name): # type: ignore[reportAttributeAccessIssue, attr-defined] + self.chat_client._update_agent_name(self.name) # type: ignore[reportAttributeAccessIssue, attr-defined] async def run( self, diff --git a/python/packages/main/agent_framework/_clients.py b/python/packages/main/agent_framework/_clients.py index f80d1f5dc1..2a6735fe0b 100644 --- a/python/packages/main/agent_framework/_clients.py +++ b/python/packages/main/agent_framework/_clients.py @@ -2,32 +2,29 @@ import asyncio from abc import ABC, abstractmethod -from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, MutableSequence, Sequence -from functools import wraps +from collections.abc import AsyncIterable, Callable, MutableMapping, MutableSequence, Sequence from typing import TYPE_CHECKING, Any, Generic, Literal, Protocol, TypeVar, runtime_checkable -from pydantic import BaseModel +from pydantic import BaseModel, Field from ._logging import get_logger from ._mcp import MCPTool from ._pydantic import AFBaseModel from ._threads import ChatMessageStore -from ._tools import AIFunction, ToolProtocol +from ._tools import ToolProtocol from ._types import ( ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, ChatToolMode, - Contents, - FunctionCallContent, - FunctionResultContent, GeneratedEmbeddings, ) if TYPE_CHECKING: from ._agents import ChatAgent + TInput = TypeVar("TInput", contravariant=True) TEmbedding = TypeVar("TEmbedding") TBaseChatClient = TypeVar("TBaseChatClient", bound="BaseChatClient") @@ -38,215 +35,8 @@ __all__ = [ "BaseChatClient", "ChatClientProtocol", "EmbeddingGenerator", - "use_tool_calling", ] -# region Tool Calling Functions and Decorators - - -async def _auto_invoke_function( - function_call_content: FunctionCallContent, - custom_args: dict[str, Any] | None = None, - *, - tool_map: dict[str, AIFunction[BaseModel, Any]], - sequence_index: int | None = None, - request_index: int | None = None, -) -> Contents: - """Invoke a function call requested by the agent, applying filters that are defined in the agent.""" - tool: AIFunction[BaseModel, Any] | None = tool_map.get(function_call_content.name) - if tool is None: - raise KeyError(f"No tool or function named '{function_call_content.name}'") - - parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {}) - - # Merge with user-supplied args; right-hand side dominates, so parsed args win on conflicts. - merged_args: dict[str, Any] = (custom_args or {}) | parsed_args - args = tool.input_model.model_validate(merged_args) - exception = None - try: - function_result = await tool.invoke(arguments=args, tool_call_id=function_call_content.call_id) - except Exception as ex: - exception = ex - function_result = None - return FunctionResultContent( - call_id=function_call_content.call_id, - exception=exception, - result=function_result, - ) - - -def _tool_call_non_streaming( - func: Callable[..., Awaitable["ChatResponse"]], -) -> Callable[..., Awaitable["ChatResponse"]]: - """Decorate the internal _inner_get_response method to enable tool calls.""" - - @wraps(func) - async def wrapper( - self: "BaseChatClient", - *, - messages: MutableSequence[ChatMessage], - chat_options: ChatOptions, - **kwargs: Any, - ) -> ChatResponse: - response: ChatResponse | None = None - fcc_messages: list[ChatMessage] = [] - for attempt_idx in range(getattr(self, "__maximum_iterations_per_request", 10)): - response = await func(self, messages=messages, chat_options=chat_options, **kwargs) - # if there are function calls, we will handle them first - function_results = { - it.call_id for it in response.messages[0].contents if isinstance(it, FunctionResultContent) - } - function_calls = [ - it - for it in response.messages[0].contents - if isinstance(it, FunctionCallContent) and it.call_id not in function_results - ] - if function_calls: - # Run all function calls concurrently - results = await asyncio.gather(*[ - _auto_invoke_function( - function_call, - custom_args=kwargs, - tool_map={t.name: t for t in chat_options.tools or [] if isinstance(t, AIFunction)}, # type: ignore[reportPrivateUsage] - sequence_index=seq_idx, - request_index=attempt_idx, - ) - for seq_idx, function_call in enumerate(function_calls) - ]) - # add a single ChatMessage to the response with the results - result_message = ChatMessage(role="tool", contents=results) - response.messages.append(result_message) - # response should contain 2 messages after this, - # one with function call contents - # and one with function result contents - # the amount and call_id's should match - # this runs in every but the first run - # we need to keep track of all function call messages - fcc_messages.extend(response.messages) - # and add them as additional context to the messages - if chat_options.store: - messages.clear() - messages.append(result_message) - else: - messages.extend(response.messages) - continue - # If we reach this point, it means there were no function calls to handle, - # we'll add the previous function call and responses - # to the front of the list, so that the final response is the last one - # TODO (eavanvalkenburg): control this behavior? - if fcc_messages: - for msg in reversed(fcc_messages): - response.messages.insert(0, msg) - return response - - # Failsafe: give up on tools, ask model for plain answer - chat_options.tool_choice = "none" - self._prepare_tool_choice(chat_options=chat_options) # type: ignore[reportPrivateUsage] - response = await func(self, messages=messages, chat_options=chat_options, **kwargs) - if fcc_messages: - for msg in reversed(fcc_messages): - response.messages.insert(0, msg) - return response - - return wrapper - - -def _tool_call_streaming( - func: Callable[..., AsyncIterable["ChatResponseUpdate"]], -) -> Callable[..., AsyncIterable["ChatResponseUpdate"]]: - """Decorate the internal _inner_get_response method to enable tool calls.""" - - @wraps(func) - async def wrapper( - self: "BaseChatClient", - *, - messages: MutableSequence[ChatMessage], - chat_options: ChatOptions, - **kwargs: Any, - ) -> AsyncIterable[ChatResponseUpdate]: - """Wrap the inner get streaming response method to handle tool calls.""" - for attempt_idx in range(getattr(self, "__maximum_iterations_per_request", 10)): - function_call_returned = False - all_messages: list[ChatResponseUpdate] = [] - async for update in func(self, messages=messages, chat_options=chat_options, **kwargs): - if update.contents and any(isinstance(item, FunctionCallContent) for item in update.contents): - all_messages.append(update) - function_call_returned = True - yield update - - if not function_call_returned: - return - - # There is one FunctionCallContent response stream in the messages, combining now to create - # the full completion depending on the prompt, the message may contain both function call - # content and others - response: ChatResponse = ChatResponse.from_chat_response_updates(all_messages) - # add the single assistant response message to the history - messages.append(response.messages[0]) - function_calls = [item for item in response.messages[0].contents if isinstance(item, FunctionCallContent)] - - # When conversation id is present, it means that messages are hosted on the server. - # In this case, we need to update ChatOptions with conversation id and also clear messages - if response.conversation_id is not None: - chat_options.conversation_id = response.conversation_id - messages = [] - - if function_calls: - # Run all function calls concurrently - results = await asyncio.gather(*[ - _auto_invoke_function( - function_call, - custom_args=kwargs, - tool_map={t.name: t for t in chat_options.tools or [] if isinstance(t, AIFunction)}, # type: ignore[reportPrivateUsage] - sequence_index=seq_idx, - request_index=attempt_idx, - ) - for seq_idx, function_call in enumerate(function_calls) - ]) - yield ChatResponseUpdate(contents=results, role="tool") - function_result_msg = ChatMessage(role="tool", contents=results) - response.messages.append(function_result_msg) - messages.append(function_result_msg) - continue - - # Failsafe: give up on tools, ask model for plain answer - chat_options.tool_choice = "none" - self._prepare_tool_choice(chat_options=chat_options) # type: ignore[reportPrivateUsage] - async for update in func(self, messages=messages, chat_options=chat_options, **kwargs): - yield update - - return wrapper - - -def use_tool_calling(cls: type[TBaseChatClient]) -> type[TBaseChatClient]: - """Class decorator that enables tool calling for a chat client. - - Remarks: - This only works on classes that derive from BaseChatClient - and the `_inner_get_response` - and `_inner_get_streaming_response` methods. - It also sets a `__maximum_iterations_per_request` attribute on the class. - if you want to expose this to end_users, do a version of this: - - @property - - def maximum_iterations_per_request(self): - return getattr(self, "__maximum_iterations_per_request", 10) - - @maximum_iterations_per_request.setter - - def maximum_iterations_per_request(self, value: int) -> None: - setattr(self, "__maximum_iterations_per_request", value) - - """ - setattr(cls, "__maximum_iterations_per_request", 10) - - if inner_response := getattr(cls, "_inner_get_response", None): - cls._inner_get_response = _tool_call_non_streaming(inner_response) # type: ignore - if inner_streaming_response := getattr(cls, "_inner_get_streaming_response", None): - cls._inner_get_streaming_response = _tool_call_streaming(inner_streaming_response) # type: ignore - return cls - # region ChatClientProtocol Protocol @@ -255,6 +45,11 @@ def use_tool_calling(cls: type[TBaseChatClient]) -> type[TBaseChatClient]: class ChatClientProtocol(Protocol): """A protocol for a chat client that can generate responses.""" + @property + def additional_properties(self) -> dict[str, Any]: + """Get additional properties associated with the client.""" + ... + async def get_response( self, messages: str | ChatMessage | list[str] | list[ChatMessage], @@ -371,26 +166,35 @@ class ChatClientProtocol(Protocol): ... +# region ChatClientBase + + +def prepare_messages(messages: str | ChatMessage | list[str] | list[ChatMessage]) -> list[ChatMessage]: + """Turn the allowed input into a list of chat messages.""" + if isinstance(messages, str): + return [ChatMessage(role="user", text=messages)] + if isinstance(messages, ChatMessage): + return [messages] + return_messages: list[ChatMessage] = [] + for msg in messages: + if isinstance(msg, str): + msg = ChatMessage(role="user", text=msg) + return_messages.append(msg) + return return_messages + + class BaseChatClient(AFBaseModel, ABC): """Base class for chat clients.""" - MODEL_PROVIDER_NAME: str = "unknown" + additional_properties: dict[str, Any] = Field(default_factory=dict) + OTEL_PROVIDER_NAME: str = "unknown" # This is used for OTel setup, should be overridden in subclasses - def _prepare_messages( + def prepare_messages( self, messages: str | ChatMessage | list[str] | list[ChatMessage] ) -> MutableSequence[ChatMessage]: """Turn the allowed input into a list of chat messages.""" - if isinstance(messages, str): - return [ChatMessage(role="user", text=messages)] - if isinstance(messages, ChatMessage): - return [messages] - return_messages: list[ChatMessage] = [] - for msg in messages: - if isinstance(msg, str): - msg = ChatMessage(role="user", text=msg) - return_messages.append(msg) - return return_messages + return prepare_messages(messages) @staticmethod def _normalize_tools( @@ -537,7 +341,7 @@ class BaseChatClient(AFBaseModel, ABC): user=user, additional_properties=additional_properties or {}, ) - prepped_messages = self._prepare_messages(messages) + prepped_messages = self.prepare_messages(messages) self._prepare_tool_choice(chat_options=chat_options) return await self._inner_get_response(messages=prepped_messages, chat_options=chat_options, **kwargs) @@ -617,7 +421,7 @@ class BaseChatClient(AFBaseModel, ABC): additional_properties=additional_properties or {}, **kwargs, ) - prepped_messages = self._prepare_messages(messages) + prepped_messages = self.prepare_messages(messages) self._prepare_tool_choice(chat_options=chat_options) async for update in self._inner_get_streaming_response( messages=prepped_messages, chat_options=chat_options, **kwargs @@ -640,13 +444,13 @@ class BaseChatClient(AFBaseModel, ABC): else: chat_options.tool_choice = chat_tool_mode.mode - def service_url(self) -> str | None: + def service_url(self) -> str: """Get the URL of the service. Override this in the subclass to return the proper URL. If the service does not have a URL, return None. """ - return None + return "Unknown" def create_agent( self, diff --git a/python/packages/main/agent_framework/_tools.py b/python/packages/main/agent_framework/_tools.py index 4b12835d43..b0751b2406 100644 --- a/python/packages/main/agent_framework/_tools.py +++ b/python/packages/main/agent_framework/_tools.py @@ -1,13 +1,16 @@ # Copyright (c) Microsoft. All rights reserved. + +import asyncio import inspect import sys -from collections.abc import Awaitable, Callable, Collection +from collections.abc import AsyncIterable, Awaitable, Callable, Collection, MutableMapping, Sequence from functools import wraps -from time import perf_counter +from time import perf_counter, time_ns from typing import ( TYPE_CHECKING, Annotated, Any, + Final, Generic, Literal, Protocol, @@ -17,27 +20,39 @@ from typing import ( runtime_checkable, ) -from opentelemetry import metrics, trace +from opentelemetry import metrics from pydantic import AnyUrl, BaseModel, Field, PrivateAttr, ValidationError, create_model, field_validator from ._logging import get_logger from ._pydantic import AFBaseModel -from .exceptions import ToolException -from .telemetry import GenAIAttributes, start_as_current_span +from .exceptions import ChatClientInitializationError, ToolException +from .telemetry import ( + OPERATION_DURATION_BUCKET_BOUNDARIES, + OtelAttr, + _capture_exception, # type: ignore + get_function_span, + meter, +) if TYPE_CHECKING: - from ._types import Contents + from ._clients import ChatClientProtocol + from ._types import ( + ChatMessage, + ChatResponse, + ChatResponseUpdate, + Contents, + FunctionCallContent, + ) if sys.version_info >= (3, 12): from typing import TypedDict # pragma: no cover else: from typing_extensions import TypedDict # pragma: no cover -tracer: trace.Tracer = trace.get_tracer("agent_framework") -meter: metrics.Meter = metrics.get_meter_provider().get_meter("agent_framework") logger = get_logger() __all__ = [ + "FUNCTION_INVOKING_CHAT_CLIENT_MARKER", "AIFunction", "HostedCodeInterpreterTool", "HostedFileSearchTool", @@ -46,9 +61,17 @@ __all__ = [ "HostedWebSearchTool", "ToolProtocol", "ai_function", + "use_function_invocation", ] +logger = get_logger() +FUNCTION_INVOKING_CHAT_CLIENT_MARKER: Final[str] = "__function_invoking_chat_client__" +DEFAULT_MAX_ITERATIONS: Final[int] = 10 +TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol") +# region Helpers + + def _parse_inputs( inputs: "Contents | dict[str, Any] | str | list[Contents | dict[str, Any] | str] | None", ) -> list["Contents"]: @@ -91,6 +114,7 @@ def _parse_inputs( return parsed_inputs +# region Tools @runtime_checkable class ToolProtocol(Protocol): """Represents a generic tool that can be specified to an AI service. @@ -337,7 +361,7 @@ class HostedFileSearchTool(BaseTool): class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): - """A ToolProtocol that is callable as code. + """A AITool that is callable as code. Args: name: The name of the function. @@ -351,9 +375,10 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): input_model: type[ArgsT] _invocation_duration_histogram: metrics.Histogram = PrivateAttr( default_factory=lambda: meter.create_histogram( - GenAIAttributes.MEASUREMENT_FUNCTION_INVOCATION_DURATION.value, - unit="s", + name=OtelAttr.MEASUREMENT_FUNCTION_INVOCATION_DURATION, + unit=OtelAttr.DURATION_UNIT, description="Measures the duration of a function's execution", + explicit_bucket_boundaries_advisory=OPERATION_DURATION_BUCKET_BOUNDARIES, ) ) @@ -371,40 +396,60 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): Args: arguments: A Pydantic model instance containing the arguments for the function. - kwargs: keyword arguments to pass to the function, will not be used if `args` is provided. + otel_settings: Optional model diagnostics settings to override the default settings. + kwargs: keyword arguments to pass to the function, will not be used if `arguments` is provided. """ + global OTEL_SETTINGS + from .telemetry import OTEL_SETTINGS, setup_telemetry + tool_call_id = kwargs.pop("tool_call_id", None) if arguments is not None: if not isinstance(arguments, self.input_model): raise TypeError(f"Expected {self.input_model.__name__}, got {type(arguments).__name__}") kwargs = arguments.model_dump(exclude_none=True) - logger.info(f"Function name: {self.name}") - logger.debug(f"Function arguments: {kwargs}") - with start_as_current_span( - tracer, self, metadata={"tool_call_id": tool_call_id, "kwargs": kwargs} - ) as current_span: - attributes: dict[str, Any] = { - GenAIAttributes.MEASUREMENT_FUNCTION_TAG_NAME.value: self.name, - GenAIAttributes.TOOL_CALL_ID.value: tool_call_id, + if not OTEL_SETTINGS.ENABLED: # type: ignore + logger.info(f"Function name: {self.name}") + logger.debug(f"Function arguments: {kwargs}") + res = self.__call__(**kwargs) + result = await res if inspect.isawaitable(res) else res + logger.info(f"Function {self.name} succeeded.") + logger.debug(f"Function result: {result or 'None'}") + return result # type: ignore[reportReturnType] + + setup_telemetry() + with get_function_span( + function=self, + tool_call_id=tool_call_id, + ) as span: + hist_attributes: dict[str, Any] = { + OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME: self.name, + OtelAttr.TOOL_CALL_ID: tool_call_id or "unknown", } - starting_time_stamp = perf_counter() + logger.info(f"Function name: {self.name}") + if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore + logger.debug(f"Function arguments: {kwargs}") + start_time_stamp = perf_counter() + end_time_stamp: float | None = None try: res = self.__call__(**kwargs) result = await res if inspect.isawaitable(res) else res - logger.info(f"Function {self.name} succeeded.") - logger.debug(f"Function result: {result or 'None'}") - return result # type: ignore[reportReturnType] + end_time_stamp = perf_counter() except Exception as exception: - attributes[GenAIAttributes.ERROR_TYPE.value] = type(exception).__name__ - current_span.record_exception(exception) - current_span.set_attribute(GenAIAttributes.ERROR_TYPE.value, type(exception).__name__) - current_span.set_status(trace.StatusCode.ERROR, description=str(exception)) + end_time_stamp = perf_counter() + hist_attributes[OtelAttr.ERROR_TYPE] = type(exception).__name__ + _capture_exception(span=span, exception=exception, timestamp=time_ns()) logger.error(f"Function failed. Error: {exception}") raise + else: + logger.info(f"Function {self.name} succeeded.") + if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore + logger.debug(f"Function result: {result or 'None'}") + return result # type: ignore[reportReturnType] finally: - duration = perf_counter() - starting_time_stamp - self._invocation_duration_histogram.record(duration, attributes=attributes) - logger.info("Function completed. Duration: %fs", duration) + duration = (end_time_stamp or perf_counter()) - start_time_stamp + span.set_attribute(OtelAttr.MEASUREMENT_FUNCTION_INVOCATION_DURATION, duration) + self._invocation_duration_histogram.record(duration, attributes=hist_attributes) + logger.info("Function duration: %fs", duration) def parameters(self) -> dict[str, Any]: """Create the json schema of the parameters.""" @@ -422,6 +467,9 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): } +# region AI Function Decorator + + def _parse_annotation(annotation: Any) -> Any: """Parse a type annotation and return the corresponding type. @@ -499,3 +547,306 @@ def ai_function( return wrapper(func) return decorator(func) if func else decorator # type: ignore[reportReturnType, return-value] + + +# region Function Invoking Chat Client + + +async def _auto_invoke_function( + function_call_content: "FunctionCallContent", + custom_args: dict[str, Any] | None = None, + *, + tool_map: dict[str, AIFunction[BaseModel, Any]], + sequence_index: int | None = None, + request_index: int | None = None, +) -> "Contents": + """Invoke a function call requested by the agent, applying filters that are defined in the agent.""" + from ._types import FunctionResultContent + + tool: AIFunction[BaseModel, Any] | None = tool_map.get(function_call_content.name) + if tool is None: + raise KeyError(f"No tool or function named '{function_call_content.name}'") + + parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {}) + + # Merge with user-supplied args; right-hand side dominates, so parsed args win on conflicts. + merged_args: dict[str, Any] = (custom_args or {}) | parsed_args + args = tool.input_model.model_validate(merged_args) + exception = None + try: + function_result = await tool.invoke( + arguments=args, + tool_call_id=function_call_content.call_id, + ) # type: ignore[arg-type] + except Exception as ex: + exception = ex + function_result = None + return FunctionResultContent( + call_id=function_call_content.call_id, + exception=exception, + result=function_result, + ) + + +def _get_tool_map( + tools: "ToolProtocol \ + | Callable[..., Any] \ + | MutableMapping[str, Any] \ + | list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]", +) -> dict[str, AIFunction[Any, Any]]: + ai_function_list: dict[str, AIFunction[Any, Any]] = {} + for tool in tools if isinstance(tools, list) else [tools]: + if isinstance(tool, AIFunction): + ai_function_list[tool.name] = tool + continue + if callable(tool): + # Convert to AITool if it's a function or callable + ai_tool = ai_function(tool) + ai_function_list[ai_tool.name] = ai_tool + return ai_function_list + + +async def execute_function_calls( + custom_args: dict[str, Any], + attempt_idx: int, + function_calls: Sequence["FunctionCallContent"], + tools: "ToolProtocol \ + | Callable[..., Any] \ + | MutableMapping[str, Any] \ + | list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]", +) -> list["Contents"]: + tool_map = _get_tool_map(tools) + # Run all function calls concurrently + return await asyncio.gather(*[ + _auto_invoke_function( + function_call_content=function_call, + custom_args=custom_args, + tool_map=tool_map, + sequence_index=seq_idx, + request_index=attempt_idx, + ) + for seq_idx, function_call in enumerate(function_calls) + ]) + + +def update_conversation_id(kwargs: dict[str, Any], conversation_id: str | None) -> None: + """Update kwargs with conversation id.""" + if conversation_id is None: + return + if "chat_options" in kwargs: + kwargs["chat_options"].conversation_id = conversation_id + else: + kwargs["conversation_id"] = conversation_id + + +def _handle_function_calls_response( + func: Callable[..., Awaitable["ChatResponse"]], + *, + max_iterations: int = 10, +) -> Callable[..., Awaitable["ChatResponse"]]: + """Decorate the get_response method to enable function calls. + + Args: + func: The get_response method to decorate. + max_iterations: The maximum number of function call iterations to perform. + + """ + + def decorator( + func: Callable[..., Awaitable["ChatResponse"]], + ) -> Callable[..., Awaitable["ChatResponse"]]: + """Inner decorator.""" + + @wraps(func) + async def function_invocation_wrapper( + self: "ChatClientProtocol", + messages: "str | ChatMessage | list[str] | list[ChatMessage]", + **kwargs: Any, + ) -> "ChatResponse": + from ._clients import prepare_messages + from ._types import ChatMessage, ChatOptions, FunctionCallContent, FunctionResultContent + + prepped_messages = prepare_messages(messages) + response: "ChatResponse | None" = None + fcc_messages: "list[ChatMessage]" = [] + for attempt_idx in range(max_iterations): + response = await func(self, messages=prepped_messages, **kwargs) + # if there are function calls, we will handle them first + function_results = { + it.call_id for it in response.messages[0].contents if isinstance(it, FunctionResultContent) + } + function_calls = [ + it + for it in response.messages[0].contents + if isinstance(it, FunctionCallContent) and it.call_id not in function_results + ] + + if response.conversation_id is not None: + update_conversation_id(kwargs, response.conversation_id) + prepped_messages = [] + + tools = kwargs.get("tools") + if not tools and (chat_options := kwargs.get("chat_options")) and isinstance(chat_options, ChatOptions): + tools = chat_options.tools + if function_calls and tools: + function_results = await execute_function_calls( + custom_args=kwargs, + attempt_idx=attempt_idx, + function_calls=function_calls, + tools=tools, # type: ignore + ) + # add a single ChatMessage to the response with the results + result_message = ChatMessage(role="tool", contents=function_results) # type: ignore[call-overload] + response.messages.append(result_message) + # response should contain 2 messages after this, + # one with function call contents + # and one with function result contents + # the amount and call_id's should match + # this runs in every but the first run + # we need to keep track of all function call messages + fcc_messages.extend(response.messages) + # and add them as additional context to the messages + if kwargs.get("store"): + prepped_messages.clear() + prepped_messages.append(result_message) + else: + prepped_messages.extend(response.messages) + continue + # If we reach this point, it means there were no function calls to handle, + # we'll add the previous function call and responses + # to the front of the list, so that the final response is the last one + # TODO (eavanvalkenburg): control this behavior? + if fcc_messages: + for msg in reversed(fcc_messages): + response.messages.insert(0, msg) + return response + + # Failsafe: give up on tools, ask model for plain answer + kwargs["tool_choice"] = "none" + response = await func(self, messages=prepped_messages, **kwargs) + if fcc_messages: + for msg in reversed(fcc_messages): + response.messages.insert(0, msg) + return response + + return function_invocation_wrapper # type: ignore + + return decorator(func) + + +def _handle_function_calls_streaming_response( + func: Callable[..., AsyncIterable["ChatResponseUpdate"]], + *, + max_iterations: int = 10, +) -> Callable[..., AsyncIterable["ChatResponseUpdate"]]: + """Decorate the get_streaming_response method to handle function calls. + + Args: + func: The get_streaming_response method to decorate. + max_iterations: The maximum number of function call iterations to perform. + + """ + + def decorator( + func: Callable[..., AsyncIterable["ChatResponseUpdate"]], + ) -> Callable[..., AsyncIterable["ChatResponseUpdate"]]: + """Inner decorator.""" + + @wraps(func) + async def streaming_function_invocation_wrapper( + self: "ChatClientProtocol", + messages: "str | ChatMessage | list[str] | list[ChatMessage]", + **kwargs: Any, + ) -> AsyncIterable["ChatResponseUpdate"]: + """Wrap the inner get streaming response method to handle tool calls.""" + from ._clients import prepare_messages + from ._types import ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, FunctionCallContent + + prepped_messages = prepare_messages(messages) + for attempt_idx in range(max_iterations): + all_updates: list["ChatResponseUpdate"] = [] + async for update in func(self, messages=prepped_messages, **kwargs): + all_updates.append(update) + yield update + + # efficient check for FunctionCallContent in the updates + # if there is at least one, this stops and continuous + # if there are no FCC's then it returns + if not any(isinstance(item, FunctionCallContent) for upd in all_updates for item in upd.contents): + return + + # Now combining the updates to create the full response. + # Depending on the prompt, the message may contain both function call + # content and others + + response: "ChatResponse" = ChatResponse.from_chat_response_updates(all_updates) + # add the response message to the previous messages + prepped_messages.append(response.messages[0]) + # get the fccs + function_calls = [ + item for item in response.messages[0].contents if isinstance(item, FunctionCallContent) + ] + + # When conversation id is present, it means that messages are hosted on the server. + # In this case, we need to update kwargs with conversation id and also clear messages + if response.conversation_id is not None: + update_conversation_id(kwargs, response.conversation_id) + prepped_messages = [] + + tools = kwargs.get("tools") + if not tools and (chat_options := kwargs.get("chat_options")) and isinstance(chat_options, ChatOptions): + tools = chat_options.tools + + if function_calls and tools: + function_results = await execute_function_calls( + custom_args=kwargs, + attempt_idx=attempt_idx, + function_calls=function_calls, + tools=tools, # type: ignore[reportArgumentType] + ) + function_result_msg = ChatMessage(role="tool", contents=function_results) + yield ChatResponseUpdate(contents=function_results, role="tool") + response.messages.append(function_result_msg) + prepped_messages.append(function_result_msg) + continue + + # Failsafe: give up on tools, ask model for plain answer + kwargs["tool_choice"] = "none" + async for update in func(self, messages=prepped_messages, **kwargs): + yield update + + return streaming_function_invocation_wrapper + + return decorator(func) + + +def use_function_invocation( + chat_client: type[TChatClient], +) -> type[TChatClient]: + """Class decorator that enables tool calling for a chat client.""" + if getattr(chat_client, FUNCTION_INVOKING_CHAT_CLIENT_MARKER, False): + return chat_client + + max_iterations = DEFAULT_MAX_ITERATIONS + + try: + chat_client.get_response = _handle_function_calls_response( # type: ignore + func=chat_client.get_response, # type: ignore + max_iterations=max_iterations, + ) + except AttributeError as ex: + raise ChatClientInitializationError( + f"Chat client {chat_client.__name__} does not have a get_response method, cannot apply function invocation." + ) from ex + try: + chat_client.get_streaming_response = _handle_function_calls_streaming_response( # type: ignore + func=chat_client.get_streaming_response, + max_iterations=max_iterations, + ) + except AttributeError as ex: + raise ChatClientInitializationError( + f"Chat client {chat_client.__name__} does not have a get_streaming_response method, " + "cannot apply function invocation." + ) from ex + setattr(chat_client, FUNCTION_INVOKING_CHAT_CLIENT_MARKER, True) + return chat_client diff --git a/python/packages/main/agent_framework/_types.py b/python/packages/main/agent_framework/_types.py index 60108d982a..1a7e54efef 100644 --- a/python/packages/main/agent_framework/_types.py +++ b/python/packages/main/agent_framework/_types.py @@ -933,7 +933,7 @@ class FunctionCallContent(BaseContent): if not isinstance(other, FunctionCallContent): raise TypeError("Incompatible type") if other.call_id and self.call_id != other.call_id: - raise AdditionItemMismatch + raise AdditionItemMismatch("", log_level=None) if not self.arguments: arguments = other.arguments elif not other.arguments: diff --git a/python/packages/main/agent_framework/exceptions.py b/python/packages/main/agent_framework/exceptions.py index 5e6a28b1e2..242b83c995 100644 --- a/python/packages/main/agent_framework/exceptions.py +++ b/python/packages/main/agent_framework/exceptions.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import logging -from typing import Any +from typing import Any, Literal logger = logging.getLogger("agent_framework") @@ -12,12 +12,20 @@ class AgentFrameworkException(Exception): Automatically logs the message as debug. """ - def __init__(self, message: str, inner_exception: Exception | None = None, *args: Any): + def __init__( + self, + message: str, + inner_exception: Exception | None = None, + log_level: Literal[0] | Literal[10] | Literal[20] | Literal[30] | Literal[40] | Literal[50] | None = 10, + *args: Any, + **kwargs: Any, + ): """Create an AgentFrameworkException. - This emits a debug log, with the inner_exception if provided. + This emits a debug log (by default), with the inner_exception if provided. """ - logger.debug(message, exc_info=inner_exception) + if log_level is not None: + logger.log(log_level, message, exc_info=inner_exception) if inner_exception: super().__init__(message, inner_exception, *args) # type: ignore super().__init__(message, *args) # type: ignore @@ -35,6 +43,24 @@ class AgentExecutionException(AgentException): pass +class AgentInitializationError(AgentException): + """An error occurred while initializing the agent.""" + + pass + + +class ChatClientException(AgentFrameworkException): + """An error occurred while dealing with a chat client.""" + + pass + + +class ChatClientInitializationError(ChatClientException): + """An error occurred while initializing the chat client.""" + + pass + + # region Service Exceptions @@ -101,9 +127,4 @@ class ToolExecutionException(ToolException): class AdditionItemMismatch(AgentFrameworkException): """An error occurred while adding two types.""" - def __init__(self) -> None: - """Create an AdditionItemMismatch. - - Unlike the AgentFrameworkException, this does not log the message automatically, - """ - pass + pass diff --git a/python/packages/main/agent_framework/openai/_assistants_client.py b/python/packages/main/agent_framework/openai/_assistants_client.py index da7a209112..ea81734481 100644 --- a/python/packages/main/agent_framework/openai/_assistants_client.py +++ b/python/packages/main/agent_framework/openai/_assistants_client.py @@ -20,8 +20,8 @@ from openai.types.beta.threads.run_submit_tool_outputs_params import ToolOutput from openai.types.beta.threads.runs import RunStep from pydantic import Field, PrivateAttr, SecretStr, ValidationError -from .._clients import BaseChatClient, use_tool_calling -from .._tools import AIFunction, HostedCodeInterpreterTool, HostedFileSearchTool +from .._clients import BaseChatClient +from .._tools import AIFunction, HostedCodeInterpreterTool, HostedFileSearchTool, use_function_invocation from .._types import ( ChatMessage, ChatOptions, @@ -50,8 +50,8 @@ else: __all__ = ["OpenAIAssistantsClient"] +@use_function_invocation @use_telemetry -@use_tool_calling class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): """OpenAI Assistants client.""" @@ -166,7 +166,9 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): # Get the thread ID thread_id: str | None = ( - chat_options.conversation_id if chat_options.conversation_id is not None else self.thread_id + chat_options.conversation_id + if chat_options.conversation_id is not None + else run_options.get("conversation_id", self.thread_id) ) if thread_id is None and tool_results is not None: diff --git a/python/packages/main/agent_framework/openai/_chat_client.py b/python/packages/main/agent_framework/openai/_chat_client.py index bd41ac48d3..aa98257804 100644 --- a/python/packages/main/agent_framework/openai/_chat_client.py +++ b/python/packages/main/agent_framework/openai/_chat_client.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import json +import sys from collections.abc import AsyncIterable, Mapping, MutableMapping, MutableSequence, Sequence from datetime import datetime from itertools import chain @@ -15,9 +16,9 @@ from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall from pydantic import BaseModel, SecretStr, ValidationError -from .._clients import BaseChatClient, use_tool_calling +from .._clients import BaseChatClient from .._logging import get_logger -from .._tools import AIFunction, HostedWebSearchTool, ToolProtocol +from .._tools import AIFunction, HostedWebSearchTool, ToolProtocol, use_function_invocation from .._types import ( ChatMessage, ChatOptions, @@ -41,14 +42,17 @@ from ..telemetry import use_telemetry from ._exceptions import OpenAIContentFilterException from ._shared import OpenAIBase, OpenAIConfigMixin, OpenAISettings, prepare_function_call_results +if sys.version_info >= (3, 12): + from typing import override # type: ignore # pragma: no cover +else: + from typing_extensions import override # type: ignore[import] # pragma: no cover + __all__ = ["OpenAIChatClient"] logger = get_logger("agent_framework.openai") # region Base Client -@use_telemetry -@use_tool_calling class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): """OpenAI Chat completion class.""" @@ -233,11 +237,26 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): ) def _usage_details_from_openai(self, usage: CompletionUsage) -> UsageDetails: - return UsageDetails( - prompt_tokens=usage.prompt_tokens, - completion_tokens=usage.completion_tokens, - total_tokens=usage.total_tokens, + details = UsageDetails( + input_token_count=usage.prompt_tokens, + output_token_count=usage.completion_tokens, + total_token_count=usage.total_tokens, ) + if usage.completion_tokens_details: + if tokens := usage.completion_tokens_details.accepted_prediction_tokens: + details["completion/accepted_prediction_tokens"] = tokens + if tokens := usage.completion_tokens_details.audio_tokens: + details["completion/audio_tokens"] = tokens + if tokens := usage.completion_tokens_details.reasoning_tokens: + details["completion/reasoning_tokens"] = tokens + if tokens := usage.completion_tokens_details.rejected_prediction_tokens: + details["completion/rejected_prediction_tokens"] = tokens + if usage.prompt_tokens_details: + if tokens := usage.prompt_tokens_details.audio_tokens: + details["prompt/audio_tokens"] = tokens + if tokens := usage.prompt_tokens_details.cached_tokens: + details["prompt/cached_tokens"] = tokens + return details def _parse_text_from_choice(self, choice: Choice | ChunkChoice) -> TextContent | None: """Parse the choice into a TextContent object.""" @@ -362,13 +381,14 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): case _: return content.model_dump(exclude_none=True) - def service_url(self) -> str | None: + @override + def service_url(self) -> str: """Get the URL of the service. Override this in the subclass to return the proper URL. If the service does not have a URL, return None. """ - return str(self.client.base_url) if self.client else None + return str(self.client.base_url) if self.client else "Unknown" # region Public client @@ -376,6 +396,8 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): TOpenAIChatClient = TypeVar("TOpenAIChatClient", bound="OpenAIChatClient") +@use_function_invocation +@use_telemetry class OpenAIChatClient(OpenAIConfigMixin, OpenAIBaseChatClient): """OpenAI Chat completion class.""" diff --git a/python/packages/main/agent_framework/openai/_responses_client.py b/python/packages/main/agent_framework/openai/_responses_client.py index f684dcc31e..61c40eaf79 100644 --- a/python/packages/main/agent_framework/openai/_responses_client.py +++ b/python/packages/main/agent_framework/openai/_responses_client.py @@ -25,7 +25,7 @@ from openai.types.responses.web_search_tool_param import UserLocation as WebSear from openai.types.responses.web_search_tool_param import WebSearchToolParam from pydantic import BaseModel, SecretStr, ValidationError -from .._clients import BaseChatClient, use_tool_calling +from .._clients import BaseChatClient from .._logging import get_logger from .._tools import ( AIFunction, @@ -34,6 +34,7 @@ from .._tools import ( HostedMCPTool, HostedWebSearchTool, ToolProtocol, + use_function_invocation, ) from .._types import ( ChatMessage, @@ -406,7 +407,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): tool_args["file_ids"] = [] for tool_input in tool.inputs: if isinstance(tool_input, HostedFileContent): - tool_args["file_ids"].append(tool_input.file_id) + tool_args["file_ids"].append(tool_input.file_id) # type: ignore[attr-defined] if not tool_args["file_ids"]: tool_args.pop("file_ids") response_tools.append( @@ -1040,8 +1041,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): TOpenAIResponsesClient = TypeVar("TOpenAIResponsesClient", bound="OpenAIResponsesClient") +@use_function_invocation @use_telemetry -@use_tool_calling class OpenAIResponsesClient(OpenAIConfigMixin, OpenAIBaseResponsesClient): """OpenAI Responses client class.""" diff --git a/python/packages/main/agent_framework/openai/_shared.py b/python/packages/main/agent_framework/openai/_shared.py index 5a7b5c5a75..de8ada2ca4 100644 --- a/python/packages/main/agent_framework/openai/_shared.py +++ b/python/packages/main/agent_framework/openai/_shared.py @@ -60,7 +60,7 @@ def prepare_function_call_results(content: Contents | Any | list[Contents | Any] results.extend(res) else: results.append(res) - return results[0] if len(results) == 1 else results + return results[0] if len(results) == 1 else json.dumps(results) if isinstance(content, BaseModel): return content.model_dump_json(exclude_none=True, exclude={"raw_representation", "additional_properties"}) # fallback @@ -127,7 +127,7 @@ class OpenAIBase(AFBaseModel): class OpenAIConfigMixin(OpenAIBase): """Internal class for configuring a connection to an OpenAI service.""" - MODEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] + OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def __init__( diff --git a/python/packages/main/agent_framework/telemetry.py b/python/packages/main/agent_framework/telemetry.py index b52708fac3..604bed1388 100644 --- a/python/packages/main/agent_framework/telemetry.py +++ b/python/packages/main/agent_framework/telemetry.py @@ -1,157 +1,67 @@ # Copyright (c) Microsoft. All rights reserved. -import functools import json import logging import os -from collections.abc import AsyncIterable, Awaitable, Callable, MutableSequence +from collections.abc import AsyncIterable, Awaitable, Callable, Generator +from contextlib import contextmanager from enum import Enum +from functools import wraps +from time import perf_counter, time_ns from typing import TYPE_CHECKING, Any, ClassVar, Final, TypeVar -from opentelemetry import trace +from opentelemetry import metrics +from opentelemetry.semconv_ai import GenAISystem, Meters, SpanAttributes from opentelemetry.trace import Span, StatusCode, get_tracer, use_span +from opentelemetry.version import __version__ as otel_version +from pydantic import PrivateAttr from . import __version__ as version_info from ._logging import get_logger from ._pydantic import AFBaseSettings +from .exceptions import AgentInitializationError, ChatClientInitializationError if TYPE_CHECKING: # pragma: no cover + from opentelemetry.metrics import Histogram + from opentelemetry.sdk.resources import Resource from opentelemetry.util._decorator import _AgnosticContextManager # type: ignore[reportPrivateUsage] - from ._agents import AgentProtocol, ChatAgent - from ._clients import BaseChatClient + from ._agents import AgentProtocol + from ._clients import ChatClientProtocol from ._threads import AgentThread from ._tools import AIFunction from ._types import ( AgentRunResponse, AgentRunResponseUpdate, ChatMessage, - ChatOptions, ChatResponse, ChatResponseUpdate, + Contents, + FinishReason, ) -TBaseChatClient = TypeVar("TBaseChatClient", bound="BaseChatClient") -TChatClientAgent = TypeVar("TChatClientAgent", bound="ChatAgent") +TAgent = TypeVar("TAgent", bound="AgentProtocol") +TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol") -tracer = get_tracer("agent_framework") logger = get_logger() __all__ = [ "AGENT_FRAMEWORK_USER_AGENT", "APP_INFO", + "OPEN_TELEMETRY_AGENT_MARKER", + "OPEN_TELEMETRY_CHAT_CLIENT_MARKER", + "OTEL_SETTINGS", "USER_AGENT_KEY", "prepend_agent_framework_to_user_agent", + "setup_telemetry", "use_agent_telemetry", "use_telemetry", ] - -# We're recording multiple events for the chat history, some of them are emitted within (hundreds of) -# nanoseconds of each other. The default timestamp resolution is not high enough to guarantee unique -# timestamps for each message. Also Azure Monitor truncates resolution to microseconds and some other -# backends truncate to milliseconds. -# -# But we need to give users a way to restore chat message order, so we're incrementing the timestamp -# by 1 microsecond for each message. -# -# This is a workaround, we'll find a generic and better solution - see -# https://github.com/open-telemetry/semantic-conventions/issues/1701 -class ChatMessageListTimestampFilter(logging.Filter): - """A filter to increment the timestamp of INFO logs by 1 microsecond.""" - - INDEX_KEY: ClassVar[str] = "CHAT_MESSAGE_INDEX" - - def filter(self, record: logging.LogRecord) -> bool: - """Increment the timestamp of INFO logs by 1 microsecond.""" - if hasattr(record, self.INDEX_KEY): - idx = getattr(record, self.INDEX_KEY) - record.created += idx * 1e-6 - return True - - -# Creates a tracer from the global tracer provider -logger.addFilter(ChatMessageListTimestampFilter()) - - -class GenAIAttributes(str, Enum): - """Enum to capture the attributes used in OpenTelemetry for Generative AI. - - Based on: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/ - and https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/ - - Should always be used, with `.value` to get the string representation. - """ - - OPERATION = "gen_ai.operation.name" - SYSTEM = "gen_ai.system" - ERROR_TYPE = "error.type" - PORT = "server.port" - ADDRESS = "server.address" - SPAN_ID = "SpanId" - TRACE_ID = "TraceId" - # Request attributes - MODEL = "gen_ai.request.model" - SEED = "gen_ai.request.seed" - ENCODING_FORMATS = "gen_ai.request.encoding_formats" - FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty" - MAX_TOKENS = "gen_ai.request.max_tokens" - PRESENCE_PENALTY = "gen_ai.request.presence_penalty" - STOP_SEQUENCES = "gen_ai.request.stop_sequences" - TEMPERATURE = "gen_ai.request.temperature" - TOP_K = "gen_ai.request.top_k" - TOP_P = "gen_ai.request.top_p" - CHOICE_COUNT = "gen_ai.request.choice.count" - # Response attributes - FINISH_REASONS = "gen_ai.response.finish_reasons" - RESPONSE_ID = "gen_ai.response.id" - RESPONSE_MODEL = "gen_ai.response.model" - # Usage attributes - INPUT_TOKENS = "gen_ai.usage.input_tokens" - OUTPUT_TOKENS = "gen_ai.usage.output_tokens" - # Tool attributes - TOOL_CALL_ID = "gen_ai.tool.call.id" - TOOL_DESCRIPTION = "gen_ai.tool.description" - TOOL_NAME = "gen_ai.tool.name" - AGENT_ID = "gen_ai.agent.id" - # Agent attributes - AGENT_NAME = "gen_ai.agent.name" - AGENT_DESCRIPTION = "gen_ai.agent.description" - CONVERSATION_ID = "gen_ai.conversation.id" - DATA_SOURCE_ID = "gen_ai.data_source.id" - OUTPUT_TYPE = "gen_ai.output.type" - - # Activity events - EVENT_NAME = "event.name" - SYSTEM_MESSAGE = "gen_ai.system.message" - USER_MESSAGE = "gen_ai.user.message" - ASSISTANT_MESSAGE = "gen_ai.assistant.message" - TOOL_MESSAGE = "gen_ai.tool.message" - CHOICE = "gen_ai.choice" - PROMPT = "gen_ai.prompt" - - # Operation names - CHAT_COMPLETION_OPERATION = "chat" - TOOL_EXECUTION_OPERATION = "execute_tool" - # Describes GenAI agent creation and is usually applicable when working with remote agent services. - AGENT_CREATE_OPERATION = "create_agent" - AGENT_INVOKE_OPERATION = "invoke_agent" - - # Agent Framework specific attributes - MEASUREMENT_FUNCTION_TAG_NAME = "agent_framework.function.name" - MEASUREMENT_FUNCTION_INVOCATION_DURATION = "agent_framework.function.invocation.duration" - AGENT_FRAMEWORK_GEN_AI_SYSTEM = "microsoft.agent_framework" - - -ROLE_EVENT_MAP = { - "system": GenAIAttributes.SYSTEM_MESSAGE.value, - "user": GenAIAttributes.USER_MESSAGE.value, - "assistant": GenAIAttributes.ASSISTANT_MESSAGE.value, - "tool": GenAIAttributes.TOOL_MESSAGE.value, -} -# Note that if this environment variable does not exist, telemetry is enabled. -TELEMETRY_DISABLED_ENV_VAR = "AZURE_TELEMETRY_DISABLED" -IS_TELEMETRY_ENABLED = os.environ.get(TELEMETRY_DISABLED_ENV_VAR, "false").lower() not in ["true", "1"] +# region User Agents +# Note that if this environment variable does not exist, user agent telemetry is enabled. +USER_AGENT_TELEMETRY_DISABLED_ENV_VAR = "AGENT_FRAMEWORK_USER_AGENT_DISABLED" +IS_TELEMETRY_ENABLED = os.environ.get(USER_AGENT_TELEMETRY_DISABLED_ENV_VAR, "false").lower() not in ["true", "1"] APP_INFO = ( { @@ -192,11 +102,260 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) return headers +# region Otel + +tracer = get_tracer("agent_framework", otel_version) +meter = metrics.get_meter_provider().get_meter("agent_framework", otel_version) + +OTEL_METRICS: Final[str] = "__otel_metrics__" +OPEN_TELEMETRY_CHAT_CLIENT_MARKER: Final[str] = "__open_telemetry_chat_client__" +OPEN_TELEMETRY_AGENT_MARKER: Final[str] = "__open_telemetry_agent__" +TOKEN_USAGE_BUCKET_BOUNDARIES: Final[tuple[float, ...]] = ( + 1, + 4, + 16, + 64, + 256, + 1024, + 4096, + 16384, + 65536, + 262144, + 1048576, + 4194304, + 16777216, + 67108864, +) +OPERATION_DURATION_BUCKET_BOUNDARIES: Final[tuple[float, ...]] = ( + 0.01, + 0.02, + 0.04, + 0.08, + 0.16, + 0.32, + 0.64, + 1.28, + 2.56, + 5.12, + 10.24, + 20.48, + 40.96, + 81.92, +) + + +# We're recording multiple events for the chat history, some of them are emitted within (hundreds of) +# nanoseconds of each other. The default timestamp resolution is not high enough to guarantee unique +# timestamps for each message. Also Azure Monitor truncates resolution to microseconds and some other +# backends truncate to milliseconds. +# +# But we need to give users a way to restore chat message order, so we're incrementing the timestamp +# by 1 microsecond for each message. +# +# This is a workaround, we'll find a generic and better solution - see +# https://github.com/open-telemetry/semantic-conventions/issues/1701 +class ChatMessageListTimestampFilter(logging.Filter): + """A filter to increment the timestamp of INFO logs by 1 microsecond.""" + + INDEX_KEY: ClassVar[str] = "chat_message_index" + + def filter(self, record: logging.LogRecord) -> bool: + """Increment the timestamp of INFO logs by 1 microsecond.""" + if hasattr(record, self.INDEX_KEY): + idx = getattr(record, self.INDEX_KEY) + record.created += idx * 1e-6 + return True + + +logger.addFilter(ChatMessageListTimestampFilter()) + + +class OtelAttr(str, Enum): + """Enum to capture the attributes used in OpenTelemetry for Generative AI. + + Based on: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/ + and https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/ + """ + + OPERATION = "gen_ai.operation.name" + PROVIDER_NAME = "gen_ai.provider.name" + ERROR_TYPE = "error.type" + PORT = "server.port" + ADDRESS = "server.address" + SPAN_ID = "SpanId" + TRACE_ID = "TraceId" + # Request attributes + SEED = "gen_ai.request.seed" + ENCODING_FORMATS = "gen_ai.request.encoding_formats" + FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty" + PRESENCE_PENALTY = "gen_ai.request.presence_penalty" + STOP_SEQUENCES = "gen_ai.request.stop_sequences" + TOP_K = "gen_ai.request.top_k" + CHOICE_COUNT = "gen_ai.request.choice.count" + # Response attributes + FINISH_REASONS = "gen_ai.response.finish_reasons" + RESPONSE_ID = "gen_ai.response.id" + # Usage attributes + INPUT_TOKENS = "gen_ai.usage.input_tokens" + OUTPUT_TOKENS = "gen_ai.usage.output_tokens" + # Tool attributes + TOOL_CALL_ID = "gen_ai.tool.call.id" + TOOL_DESCRIPTION = "gen_ai.tool.description" + TOOL_NAME = "gen_ai.tool.name" + TOOL_TYPE = "gen_ai.tool.type" + # Agent attributes + AGENT_ID = "gen_ai.agent.id" + # Client attributes + # replaced TOKEN with T, because both ruff and bandit, + # complain about TOKEN being a potential secret + T_UNIT = "tokens" + T_TYPE = "gen_ai.token.type" + T_TYPE_INPUT = "input" + T_TYPE_OUTPUT = "output" + DURATION_UNIT = "s" + # Agent attributes + AGENT_NAME = "gen_ai.agent.name" + AGENT_DESCRIPTION = "gen_ai.agent.description" + CONVERSATION_ID = "gen_ai.conversation.id" + DATA_SOURCE_ID = "gen_ai.data_source.id" + OUTPUT_TYPE = "gen_ai.output.type" + INPUT_MESSAGES = "gen_ai.input.messages" + OUTPUT_MESSAGES = "gen_ai.output.messages" + SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions" + + # Activity events + EVENT_NAME = "event.name" + SYSTEM_MESSAGE = "gen_ai.system.message" + USER_MESSAGE = "gen_ai.user.message" + ASSISTANT_MESSAGE = "gen_ai.assistant.message" + TOOL_MESSAGE = "gen_ai.tool.message" + CHOICE = "gen_ai.choice" + + # Operation names + CHAT_COMPLETION_OPERATION = "chat" + TOOL_EXECUTION_OPERATION = "execute_tool" + # Describes GenAI agent creation and is usually applicable when working with remote agent services. + AGENT_CREATE_OPERATION = "create_agent" + AGENT_INVOKE_OPERATION = "invoke_agent" + + # Agent Framework specific attributes + MEASUREMENT_FUNCTION_TAG_NAME = "agent_framework.function.name" + MEASUREMENT_FUNCTION_INVOCATION_DURATION = "agent_framework.function.invocation.duration" + AGENT_FRAMEWORK_GEN_AI_SYSTEM = "microsoft.agent_framework" + + def __repr__(self) -> str: + return self.value + + def __str__(self) -> str: + return self.value + + +ROLE_EVENT_MAP = { + "system": OtelAttr.SYSTEM_MESSAGE, + "user": OtelAttr.USER_MESSAGE, + "assistant": OtelAttr.ASSISTANT_MESSAGE, + "tool": OtelAttr.TOOL_MESSAGE, +} +FINISH_REASON_MAP = { + "stop": "stop", + "content_filter": "content_filter", + "tool_calls": "tool_call", + "length": "length", +} + + # region Telemetry utils -class ModelDiagnosticSettings(AFBaseSettings): - """Settings for model diagnostics. +def _get_exporters(endpoint: str | None = None, connection_string: str | None = None) -> dict[str, list[Any]]: + """Create the different exporters based on the connection string and endpoint.""" + from azure.monitor.opentelemetry.exporter import ( # pylint: disable=import-error,no-name-in-module + AzureMonitorLogExporter, + AzureMonitorMetricExporter, + AzureMonitorTraceExporter, + ) + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + + exporters: dict[str, Any] = {} + exporters.setdefault("log", []) + exporters.setdefault("trace", []) + exporters.setdefault("metric", []) + if endpoint: + exporters["log"].append(OTLPLogExporter(endpoint=endpoint)) + exporters["trace"].append(OTLPSpanExporter(endpoint=endpoint)) + exporters["metric"].append(OTLPMetricExporter(endpoint=endpoint)) + if connection_string: + exporters["log"].append(AzureMonitorLogExporter(connection_string=connection_string)) + exporters["trace"].append(AzureMonitorTraceExporter(connection_string=connection_string)) + exporters["metric"].append(AzureMonitorMetricExporter(connection_string=connection_string)) + return exporters + + +def _configure_tracing(exporters: dict[str, list[Any]], resource: "Resource") -> None: + from opentelemetry._events import set_event_logger_provider + from opentelemetry._logs import set_logger_provider + from opentelemetry.metrics import set_meter_provider + from opentelemetry.sdk._events import EventLoggerProvider + from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler + from opentelemetry.sdk._logs.export import BatchLogRecordProcessor + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + from opentelemetry.sdk.metrics.view import DropAggregation, View + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from opentelemetry.trace import set_tracer_provider + + # Tracing + tracer_provider = TracerProvider(resource=resource) + for exporter in exporters.get("trace", []): + tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) + set_tracer_provider(tracer_provider) + + # Logging + logger_provider = LoggerProvider(resource=resource) + for exporter in exporters.get("log", []): + logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter)) + set_logger_provider(logger_provider) + logger = get_logger() + if not any(isinstance(handler, LoggingHandler) for handler in logger.handlers): + handler = LoggingHandler(logger_provider=logger_provider) + logger.addHandler(handler) + logger.setLevel(logging.NOTSET) + + # Events + event_logger_provider = EventLoggerProvider(logger_provider) + set_event_logger_provider(event_logger_provider) + + # metrics + + metric_readers = [ + PeriodicExportingMetricReader(exporter, export_interval_millis=5000) for exporter in exporters.get("metric", []) + ] + meter_provider = MeterProvider( + metric_readers=metric_readers, + resource=resource, + views=[ + # Dropping all instrument names except for those starting with "agent_framework" + View(instrument_name="*", aggregation=DropAggregation()), + View(instrument_name="agent_framework*"), + View(instrument_name="gen_ai*"), + ], + ) + # Sets the global default meter provider + set_meter_provider(meter_provider) + + +OTEL_ENABLED_ENV_VAR = "ENABLE_OTEL" +SENSITIVE_DATA_ENV_VAR = "ENABLE_SENSITIVE_DATA" +MONITOR_CONNECTION_STRING_ENV_VAR = "MONITOR_CONNECTION_STRING" +MONITOR_LIVE_METRICS_ENV_VAR = "MONITOR_LIVE_METRICS" +OTLP_ENDPOINT_ENV_VAR = "OTLP_ENDPOINT" + + +class OtelSettings(AFBaseSettings): + """Settings for Open Telemetry. The settings are first loaded from environment variables with the prefix 'AGENT_FRAMEWORK_GENAI_'. @@ -209,17 +368,27 @@ class ModelDiagnosticSettings(AFBaseSettings): Warning: Sensitive events should only be enabled on test and development environments. - Required settings for prefix 'AGENT_FRAMEWORK_GENAI_' are: - - enable_otel_diagnostics: bool - Enable OpenTelemetry diagnostics. Default is False. - (Env var AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS) - - enable_otel_diagnostics_sensitive: bool - Enable OpenTelemetry sensitive events. Default is False. - (Env var AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE) + Args: + enable_otel: Enable OpenTelemetry diagnostics. Default is False. + (Env var ENABLE_OTEL) + enable_sensitive_data: Enable OpenTelemetry sensitive events. Default is False. + (Env var ENABLE_SENSITIVE_DATA) + application_insights_connection_string: The Azure Monitor connection string. Default is None. + (Env var APPLICATION_INSIGHTS_CONNECTION_STRING) + application_insights_live_metrics: Enable Azure Monitor live metrics. Default is False. + (Env var APPLICATION_INSIGHTS_LIVE_METRICS) + otlp_endpoint: The OpenTelemetry Protocol (OTLP) endpoint. Default is None. + (Env var OTLP_ENDPOINT) """ - env_prefix: ClassVar[str] = "AGENT_FRAMEWORK_GENAI_" + env_prefix: ClassVar[str] = "" - enable_otel_diagnostics: bool = False - enable_otel_diagnostics_sensitive: bool = False + enable_otel: bool = False + enable_sensitive_data: bool = False + application_insights_connection_string: str | None = None + application_insights_live_metrics: bool = False + otlp_endpoint: str | None = None + _executed_setup: bool = PrivateAttr(default=False) @property def ENABLED(self) -> bool: @@ -227,292 +396,336 @@ class ModelDiagnosticSettings(AFBaseSettings): Model diagnostics are enabled if either diagnostic is enabled or diagnostic with sensitive events is enabled. """ - return self.enable_otel_diagnostics or self.enable_otel_diagnostics_sensitive + return self.enable_otel or self.enable_sensitive_data @property - def SENSITIVE_EVENTS_ENABLED(self) -> bool: + def SENSITIVE_DATA_ENABLED(self) -> bool: """Check if sensitive events are enabled. Sensitive events are enabled if the diagnostic with sensitive events is enabled. """ - return self.enable_otel_diagnostics_sensitive + return self.enable_sensitive_data + + @property + def is_setup(self) -> bool: + """Check if the setup has been executed.""" + return self._executed_setup + + def setup_telemetry(self) -> None: + """Setup telemetry based on the settings. + + If both connection_string and otlp_endpoint both will be used. + """ + if not self.ENABLED or self._executed_setup: + return + + if not self.application_insights_connection_string and not self.otlp_endpoint: + logger.warning("Telemetry is enabled but no connection string or OTLP endpoint is provided.") + return + if self.application_insights_connection_string and self.otlp_endpoint: + logger.warning("Both connection string and OTLP endpoint are provided. Azure Monitor will be used.") + + from opentelemetry.sdk.resources import Resource + from opentelemetry.semconv.attributes import service_attributes + + resource = Resource.create({service_attributes.SERVICE_NAME: "agent_framework"}) + global_logger = logging.getLogger() + global_logger.setLevel(logging.NOTSET) + if self.application_insights_connection_string: + from azure.monitor.opentelemetry import configure_azure_monitor + + configure_azure_monitor( + connection_string=self.application_insights_connection_string, + logger_name="agent_framework", + resource=resource, + enable_live_metrics=self.application_insights_live_metrics, + ) + if self.otlp_endpoint: + exporters = _get_exporters(endpoint=self.otlp_endpoint) + _configure_tracing(exporters, resource) + + self._executed_setup = True -MODEL_DIAGNOSTICS_SETTINGS = ModelDiagnosticSettings() +global OTEL_SETTINGS +OTEL_SETTINGS: OtelSettings = OtelSettings() -def start_as_current_span( - tracer: trace.Tracer, - function: "AIFunction[Any, Any]", - metadata: dict[str, Any] | None = None, -) -> "_AgnosticContextManager[Span]": - """Starts a span for the given function using the provided tracer. +def setup_telemetry( + enable_otel: bool | None = None, + enable_sensitive_data: bool | None = None, + otlp_endpoint: str | None = None, + application_insights_connection_string: str | None = None, + enable_live_metrics: bool | None = None, +) -> None: + """Setup telemetry with optionally provided settings. + + All of these values can be set through environment variables or you can pass them here, + in the case where both are present, the provided value takes precedence. + + If you have both connection_string and otlp_endpoint, the connection_string will be used. Args: - tracer: The OpenTelemetry tracer to use. - function: The function for which to start the span. - metadata: Optional metadata to include in the span attributes. + enable_otel: Enable OpenTelemetry diagnostics. Default is False. + enable_sensitive_data: Enable OpenTelemetry sensitive events. Default is False. + otlp_endpoint: The OpenTelemetry Protocol (OTLP) endpoint. Default is None. + application_insights_connection_string: The Azure Monitor connection string. Default is None. + enable_live_metrics: Enable Azure Monitor live metrics. Default is False. - Returns: - trace.Span: The started span as a context manager. """ - attributes = { - GenAIAttributes.OPERATION.value: GenAIAttributes.TOOL_EXECUTION_OPERATION.value, - GenAIAttributes.TOOL_NAME.value: function.name, - } + global OTEL_SETTINGS + if enable_otel is not None: + OTEL_SETTINGS.enable_otel = enable_otel + if enable_sensitive_data is not None: + OTEL_SETTINGS.enable_sensitive_data = enable_sensitive_data + if otlp_endpoint is not None: + OTEL_SETTINGS.otlp_endpoint = otlp_endpoint + if application_insights_connection_string is not None: + OTEL_SETTINGS.application_insights_connection_string = application_insights_connection_string + if enable_live_metrics is not None: + OTEL_SETTINGS.application_insights_live_metrics = enable_live_metrics + OTEL_SETTINGS.setup_telemetry() - tool_call_id = metadata.get("tool_call_id", None) if metadata else None - if tool_call_id: - attributes[GenAIAttributes.TOOL_CALL_ID.value] = tool_call_id - if function.description: - attributes[GenAIAttributes.TOOL_DESCRIPTION.value] = function.description - return tracer.start_as_current_span( - f"{GenAIAttributes.TOOL_EXECUTION_OPERATION.value} {function.name}", attributes=attributes +# region Chat Client Telemetry + + +def _get_duration_histogram() -> "Histogram": + return meter.create_histogram( + name=Meters.LLM_OPERATION_DURATION, + unit=OtelAttr.DURATION_UNIT, + description="Captures the duration of operations of function-invoking chat clients", + explicit_bucket_boundaries_advisory=OPERATION_DURATION_BUCKET_BOUNDARIES, ) -def _set_error(span: Span, error: Exception) -> None: - """Set an error for spans.""" - span.set_attribute(GenAIAttributes.ERROR_TYPE.value, str(type(error))) - span.set_status(StatusCode.ERROR, repr(error)) +def _get_token_usage_histogram() -> "Histogram": + return meter.create_histogram( + name=Meters.LLM_TOKEN_USAGE, + unit=OtelAttr.T_UNIT, + description="Captures the token usage of chat clients", + explicit_bucket_boundaries_advisory=TOKEN_USAGE_BUCKET_BOUNDARIES, + ) # region ChatClientProtocol -def _trace_chat_get_response( - completion_func: Callable[..., Awaitable["ChatResponse"]], +def _trace_get_response( + func: Callable[..., Awaitable["ChatResponse"]], + *, + provider_name: str = "unknown", ) -> Callable[..., Awaitable["ChatResponse"]]: """Decorator to trace chat completion activities. Args: - completion_func: The function to trace. + func: The function to trace. + provider_name: The model provider name. """ - @functools.wraps(completion_func) - async def wrap_inner_get_response( - self: "BaseChatClient", - *, - messages: MutableSequence["ChatMessage"], - chat_options: "ChatOptions", - **kwargs: Any, - ) -> "ChatResponse": - if not MODEL_DIAGNOSTICS_SETTINGS.ENABLED: - # If model diagnostics are not enabled, just return the completion - return await completion_func( - self, - messages=messages, - chat_options=chat_options, + def decorator(func: Callable[..., Awaitable["ChatResponse"]]) -> Callable[..., Awaitable["ChatResponse"]]: + """Inner decorator.""" + + @wraps(func) + async def trace_get_response( + self: "ChatClientProtocol", + messages: "str | ChatMessage | list[str] | list[ChatMessage]", + **kwargs: Any, + ) -> "ChatResponse": + global OTEL_SETTINGS + if not OTEL_SETTINGS.ENABLED: + # If model diagnostics are not enabled, just return the completion + return await func( + self, + messages=messages, + **kwargs, + ) + setup_telemetry() + if "token_usage_histogram" not in self.additional_properties: + self.additional_properties["token_usage_histogram"] = _get_token_usage_histogram() + if "operation_duration_histogram" not in self.additional_properties: + self.additional_properties["operation_duration_histogram"] = _get_duration_histogram() + model_id = str(kwargs.get("ai_model_id") or getattr(self, "ai_model_id", "unknown")) + service_url = str( + service_url_func() + if (service_url_func := getattr(self, "service_url", None)) and callable(service_url_func) + else "unknown" + ) + attributes = _get_span_attributes( + operation_name=OtelAttr.CHAT_COMPLETION_OPERATION, + provider_name=provider_name, + model_id=model_id, + service_url=service_url, **kwargs, ) + with _get_span(attributes=attributes, span_name_attribute=SpanAttributes.LLM_REQUEST_MODEL) as span: + if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + _capture_messages(span=span, provider_name=provider_name, messages=messages) + start_time_stamp = perf_counter() + end_time_stamp: float | None = None + try: + response = await func(self, messages=messages, **kwargs) + end_time_stamp = perf_counter() + except Exception as exception: + end_time_stamp = perf_counter() + _capture_exception(span=span, exception=exception, timestamp=time_ns()) + raise + else: + duration = (end_time_stamp or perf_counter()) - start_time_stamp + attributes = _get_response_attributes(attributes, response, duration=duration) + _capture_response( + span=span, + attributes=attributes, + token_usage_histogram=self.additional_properties["token_usage_histogram"], + operation_duration_histogram=self.additional_properties["operation_duration_histogram"], + ) + if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=response.messages, + finish_reason=response.finish_reason, + output=True, + ) + return response - with use_span( - _get_chat_response_span( - GenAIAttributes.CHAT_COMPLETION_OPERATION.value, - getattr(self, "ai_model_id", chat_options.ai_model_id or "unknown"), - self.MODEL_PROVIDER_NAME, - self.service_url() if hasattr(self, "service_url") else None, - chat_options, - ), - end_on_exit=True, - ) as current_span: - _set_chat_response_input(self.MODEL_PROVIDER_NAME, messages) - try: - response = await completion_func(self, messages=messages, chat_options=chat_options, **kwargs) - _set_chat_response_output(current_span, response, self.MODEL_PROVIDER_NAME) - return response - except Exception as exception: - _set_error(current_span, exception) - raise + return trace_get_response - # Mark the wrapper decorator as a chat completion decorator - wrap_inner_get_response.__model_diagnostics_chat_client__ = True # type: ignore - - return wrap_inner_get_response + return decorator(func) -def _trace_chat_get_streaming_response( - completion_func: Callable[..., AsyncIterable["ChatResponseUpdate"]], +def _trace_get_streaming_response( + func: Callable[..., AsyncIterable["ChatResponseUpdate"]], + *, + provider_name: str = "unknown", ) -> Callable[..., AsyncIterable["ChatResponseUpdate"]]: """Decorator to trace streaming chat completion activities. Args: - completion_func: The function to trace. + func: The function to trace. + provider_name: The model provider name. """ - @functools.wraps(completion_func) - async def wrap_inner_get_streaming_response( - self: "BaseChatClient", *, messages: MutableSequence["ChatMessage"], chat_options: "ChatOptions", **kwargs: Any - ) -> AsyncIterable["ChatResponseUpdate"]: - if not MODEL_DIAGNOSTICS_SETTINGS.ENABLED: - # If model diagnostics are not enabled, just return the completion - async for streaming_chat_message_contents in completion_func( - self, messages=messages, chat_options=chat_options, **kwargs - ): - yield streaming_chat_message_contents - return + def decorator( + func: Callable[..., AsyncIterable["ChatResponseUpdate"]], + ) -> Callable[..., AsyncIterable["ChatResponseUpdate"]]: + """Inner decorator.""" - from ._types import ChatResponse + @wraps(func) + async def trace_get_streaming_response( + self: "ChatClientProtocol", messages: "str | ChatMessage | list[str] | list[ChatMessage]", **kwargs: Any + ) -> AsyncIterable["ChatResponseUpdate"]: + global OTEL_SETTINGS + if not OTEL_SETTINGS.ENABLED: + # If model diagnostics are not enabled, just return the completion + async for update in func(self, messages=messages, **kwargs): + yield update + return + setup_telemetry() + if "token_usage_histogram" not in self.additional_properties: + self.additional_properties["token_usage_histogram"] = _get_token_usage_histogram() + if "operation_duration_histogram" not in self.additional_properties: + self.additional_properties["operation_duration_histogram"] = _get_duration_histogram() - all_updates: list["ChatResponseUpdate"] = [] + model_id = kwargs.get("ai_model_id") or getattr(self, "ai_model_id", None) + service_url = str( + service_url_func() + if (service_url_func := getattr(self, "service_url", None)) and callable(service_url_func) + else "unknown" + ) + attributes = _get_span_attributes( + operation_name=OtelAttr.CHAT_COMPLETION_OPERATION, + provider_name=provider_name, + model_id=model_id, + service_url=service_url, + **kwargs, + ) + all_updates: list["ChatResponseUpdate"] = [] + with _get_span(attributes=attributes, span_name_attribute=SpanAttributes.LLM_REQUEST_MODEL) as span: + if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=messages, + ) + start_time_stamp = perf_counter() + end_time_stamp: float | None = None + try: + async for update in func(self, messages=messages, **kwargs): + all_updates.append(update) + yield update + end_time_stamp = perf_counter() + except Exception as exception: + end_time_stamp = perf_counter() + _capture_exception(span=span, exception=exception, timestamp=time_ns()) + raise + else: + duration = (end_time_stamp or perf_counter()) - start_time_stamp + from ._types import ChatResponse - with use_span( - _get_chat_response_span( - GenAIAttributes.CHAT_COMPLETION_OPERATION.value, - getattr(self, "ai_model_id", chat_options.ai_model_id or "unknown"), - self.MODEL_PROVIDER_NAME, - self.service_url() if hasattr(self, "service_url") else None, - chat_options, - ), - end_on_exit=True, - ) as current_span: - _set_chat_response_input(self.MODEL_PROVIDER_NAME, messages) - try: - async for response in completion_func(self, messages=messages, chat_options=chat_options, **kwargs): - all_updates.append(response) - yield response + response = ChatResponse.from_chat_response_updates(all_updates) + attributes = _get_response_attributes(attributes, response, duration=duration) + _capture_response( + span=span, + attributes=attributes, + token_usage_histogram=self.additional_properties["token_usage_histogram"], + operation_duration_histogram=self.additional_properties["operation_duration_histogram"], + ) - all_messages_flattened = ChatResponse.from_chat_response_updates(all_updates) - _set_chat_response_output(current_span, all_messages_flattened, self.MODEL_PROVIDER_NAME) - except Exception as exception: - _set_error(current_span, exception) - raise + if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=response.messages, + finish_reason=response.finish_reason, + output=True, + ) - # Mark the wrapper decorator as a streaming chat completion decorator - wrap_inner_get_streaming_response.__model_diagnostics_streaming_chat_completion__ = True # type: ignore - return wrap_inner_get_streaming_response + return trace_get_streaming_response + + return decorator(func) -def use_telemetry(cls: type[TBaseChatClient]) -> type[TBaseChatClient]: +def use_telemetry( + chat_client: type[TChatClient], +) -> type[TChatClient]: """Class decorator that enables telemetry for a chat client. - Remarks: - This only works on classes that derive from BaseChatClient - and the _inner_get_response - and _inner_get_streaming_response methods. - It also relies on the presence of the MODEL_PROVIDER_NAME class variable. - ``` + This needs to be applied on the class itself, not a instance of it. + + To set the proper provider name, the chat client class should have a class variable + OTEL_PROVIDER_NAME. """ - if inner_response := getattr(cls, "_inner_get_response", None): - cls._inner_get_response = _trace_chat_get_response(inner_response) # type: ignore - if inner_streaming_response := getattr(cls, "_inner_get_streaming_response", None): - cls._inner_get_streaming_response = _trace_chat_get_streaming_response(inner_streaming_response) # type: ignore - return cls + if getattr(chat_client, OPEN_TELEMETRY_CHAT_CLIENT_MARKER, False): + # Already decorated + return chat_client + provider_name = str(getattr(chat_client, "OTEL_PROVIDER_NAME", "unknown")) -def _get_chat_response_span( - operation_name: str, - model_name: str, - model_provider: str, - service_url: str | None, - chat_options: "ChatOptions", -) -> Span: - """Start a text or chat completion span for a given model. - - Note that `start_span` doesn't make the span the current span. - Use `use_span` to make it the current span as a context manager. - """ - span = tracer.start_span(f"{operation_name} {model_name}") - - # Set attributes on the span - span.set_attributes({ - GenAIAttributes.OPERATION.value: operation_name, - GenAIAttributes.SYSTEM.value: model_provider, - GenAIAttributes.MODEL.value: model_name, - GenAIAttributes.CHOICE_COUNT.value: 1, - }) - - if service_url: - span.set_attribute(GenAIAttributes.ADDRESS.value, service_url) - - if chat_options.seed is not None: - span.set_attribute(GenAIAttributes.SEED.value, chat_options.seed) - if chat_options.frequency_penalty is not None: - span.set_attribute(GenAIAttributes.FREQUENCY_PENALTY.value, chat_options.frequency_penalty) - if chat_options.max_tokens is not None: - span.set_attribute(GenAIAttributes.MAX_TOKENS.value, chat_options.max_tokens) - if chat_options.stop is not None: - span.set_attribute(GenAIAttributes.STOP_SEQUENCES.value, chat_options.stop) - if chat_options.temperature is not None: - span.set_attribute(GenAIAttributes.TEMPERATURE.value, chat_options.temperature) - if chat_options.top_p is not None: - span.set_attribute(GenAIAttributes.TOP_P.value, chat_options.top_p) - if chat_options.presence_penalty is not None: - span.set_attribute(GenAIAttributes.PRESENCE_PENALTY.value, chat_options.presence_penalty) - if "top_k" in chat_options.additional_properties: - span.set_attribute(GenAIAttributes.TOP_K.value, chat_options.additional_properties["top_k"]) - if "encoding_formats" in chat_options.additional_properties: - span.set_attribute( - GenAIAttributes.ENCODING_FORMATS.value, chat_options.additional_properties["encoding_formats"] + if provider_name not in GenAISystem.__members__: + # that list is not complete, so just logging, no consequences. + logger.debug( + f"The provider name '{provider_name}' is not recognized. " + f"Consider using one of the following: {', '.join(GenAISystem.__members__.keys())}" ) - return span + try: + chat_client.get_response = _trace_get_response(chat_client.get_response, provider_name=provider_name) # type: ignore + except AttributeError as exc: + raise ChatClientInitializationError( + f"The chat client {chat_client.__name__} does not have a get_response method.", exc + ) from exc + try: + chat_client.get_streaming_response = _trace_get_streaming_response( # type: ignore + chat_client.get_streaming_response, provider_name=provider_name + ) + except AttributeError as exc: + raise ChatClientInitializationError( + f"The chat client {chat_client.__name__} does not have a get_streaming_response method.", exc + ) from exc + setattr(chat_client, OPEN_TELEMETRY_CHAT_CLIENT_MARKER, True) -def _set_chat_response_input( - model_provider: str, - messages: MutableSequence["ChatMessage"], -) -> None: - """Set the input for a chat response. - - The logs will be associated to the current span. - """ - if MODEL_DIAGNOSTICS_SETTINGS.SENSITIVE_EVENTS_ENABLED: - for idx, message in enumerate(messages): - event_name = ROLE_EVENT_MAP.get(message.role.value) - logger.info( - message.model_dump_json(exclude_none=True), - extra={ - GenAIAttributes.EVENT_NAME.value: event_name, - GenAIAttributes.SYSTEM.value: model_provider, - ChatMessageListTimestampFilter.INDEX_KEY: idx, - }, - ) - - -def _set_chat_response_output( - current_span: Span, - response: "ChatResponse", - model_provider: str, -) -> None: - """Set the response for a given span.""" - first_completion = response.messages[0] - - # Set the response ID - response_id = ( - first_completion.additional_properties.get("id") if first_completion.additional_properties is not None else None - ) - if response_id: - current_span.set_attribute(GenAIAttributes.RESPONSE_ID.value, response_id) - - # Set the finish reason - finish_reason = response.finish_reason - if finish_reason: - current_span.set_attribute(GenAIAttributes.FINISH_REASONS.value, [finish_reason.value]) - - # Set usage attributes - - usage = response.usage_details - if usage: - if usage.input_token_count: - current_span.set_attribute(GenAIAttributes.INPUT_TOKENS.value, usage.input_token_count) - if usage.output_token_count: - current_span.set_attribute(GenAIAttributes.OUTPUT_TOKENS.value, usage.output_token_count) - - # Set the completion event - if MODEL_DIAGNOSTICS_SETTINGS.SENSITIVE_EVENTS_ENABLED: - for completion in response.messages: - full_response: dict[str, Any] = { - "message": completion.model_dump(exclude_none=True), - } - full_response["index"] = response.response_id - logger.info( - json.dumps(full_response), - extra={ - GenAIAttributes.EVENT_NAME.value: GenAIAttributes.CHOICE.value, - GenAIAttributes.SYSTEM.value: model_provider, - }, - ) + return chat_client # region Agent @@ -520,75 +733,91 @@ def _set_chat_response_output( def _trace_agent_run( run_func: Callable[..., Awaitable["AgentRunResponse"]], + provider_name: str, ) -> Callable[..., Awaitable["AgentRunResponse"]]: """Decorator to trace chat completion activities. Args: run_func: The function to trace. + provider_name: The system name used for Open Telemetry. """ - @functools.wraps(run_func) - async def wrap_run( - self: "ChatAgent", + @wraps(run_func) + async def trace_run( + self: "AgentProtocol", messages: "str | ChatMessage | list[str] | list[ChatMessage] | None" = None, *, thread: "AgentThread | None" = None, **kwargs: Any, ) -> "AgentRunResponse": - if not MODEL_DIAGNOSTICS_SETTINGS.ENABLED: - # If model diagnostics are not enabled, just return the completion - return await run_func( - self, - messages=messages, - thread=thread, - **kwargs, - ) + global OTEL_SETTINGS - with use_span( - _get_agent_run_span( - operation_name=GenAIAttributes.AGENT_INVOKE_OPERATION.value, - agent=self, - system=self.AGENT_SYSTEM_NAME, - thread=thread, - **kwargs, - ), - end_on_exit=True, - ) as current_span: - _set_agent_run_input(self.AGENT_SYSTEM_NAME, messages) + if not OTEL_SETTINGS.ENABLED: + # If model diagnostics are not enabled, just return the completion + return await run_func(self, messages=messages, thread=thread, **kwargs) + setup_telemetry() + attributes = _get_span_attributes( + operation_name=OtelAttr.AGENT_INVOKE_OPERATION, + provider_name=provider_name, + agent_id=self.id, + agent_name=self.display_name, + agent_description=self.description, + thread_id=thread.service_thread_id if thread else None, + **kwargs, + ) + with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: + if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=messages, + system_instructions=getattr(self, "instructions", None), + ) try: response = await run_func(self, messages=messages, thread=thread, **kwargs) - _set_agent_run_output(current_span, response, self.AGENT_SYSTEM_NAME) - return response except Exception as exception: - _set_error(current_span, exception) + _capture_exception(span=span, exception=exception, timestamp=time_ns()) raise + else: + attributes = _get_response_attributes(attributes, response) + _capture_response(span=span, attributes=attributes) + if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=response.messages, + output=True, + ) + return response - # Mark the wrapper decorator as a agent run decorator - wrap_run.__model_diagnostics_agent_run__ = True # type: ignore - - return wrap_run + return trace_run def _trace_agent_run_stream( - run_func: Callable[..., AsyncIterable["AgentRunResponseUpdate"]], + run_streaming_func: Callable[..., AsyncIterable["AgentRunResponseUpdate"]], + provider_name: str, ) -> Callable[..., AsyncIterable["AgentRunResponseUpdate"]]: """Decorator to trace streaming agent run activities. Args: - run_func: The function to trace. + agent: The agent that is wrapped. + run_streaming_func: The function to trace. + provider_name: The system name used for Open Telemetry. """ - @functools.wraps(run_func) - async def wrap_run_stream( - self: "ChatAgent", + @wraps(run_streaming_func) + async def trace_run_streaming( + self: "AgentProtocol", messages: "str | ChatMessage | list[str] | list[ChatMessage] | None" = None, *, thread: "AgentThread | None" = None, **kwargs: Any, ) -> AsyncIterable["AgentRunResponseUpdate"]: - if not MODEL_DIAGNOSTICS_SETTINGS.ENABLED: + global OTEL_SETTINGS + + if not OTEL_SETTINGS.ENABLED: # If model diagnostics are not enabled, just return the completion - async for streaming_agent_response in run_func(self, messages=messages, thread=thread, **kwargs): + async for streaming_agent_response in run_streaming_func(self, messages=messages, thread=thread, **kwargs): yield streaming_agent_response return @@ -596,167 +825,280 @@ def _trace_agent_run_stream( all_updates: list["AgentRunResponseUpdate"] = [] - with use_span( - _get_agent_run_span( - operation_name=GenAIAttributes.AGENT_INVOKE_OPERATION.value, - agent=self, - system=self.AGENT_SYSTEM_NAME, - thread=thread, - **kwargs, - ), - end_on_exit=True, - ) as current_span: - _set_agent_run_input(self.AGENT_SYSTEM_NAME, messages) + setup_telemetry() + attributes = _get_span_attributes( + operation_name=OtelAttr.AGENT_INVOKE_OPERATION, + provider_name=provider_name, + agent_id=self.id, + agent_name=self.display_name, + agent_description=self.description, + thread_id=thread.service_thread_id if thread else None, + **kwargs, + ) + with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: + if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=messages, + system_instructions=getattr(self, "instructions", None), + ) try: - async for response in run_func(self, messages=messages, thread=thread, **kwargs): - all_updates.append(response) - yield response - - all_messages_flattened = AgentRunResponse.from_agent_run_response_updates(all_updates) - _set_agent_run_output(current_span, all_messages_flattened, self.AGENT_SYSTEM_NAME) + async for update in run_streaming_func(self, messages=messages, thread=thread, **kwargs): + all_updates.append(update) + yield update except Exception as exception: - _set_error(current_span, exception) + _capture_exception(span=span, exception=exception, timestamp=time_ns()) raise - - # Mark the wrapper decorator as a streaming agent run decorator - wrap_run_stream.__model_diagnostics_streaming_agent_run__ = True # type: ignore - return wrap_run_stream - - -def use_agent_telemetry(cls: type[TChatClientAgent]) -> type[TChatClientAgent]: - """Class decorator that enables telemetry for an agent.""" - if run := getattr(cls, "run", None): - cls.run = _trace_agent_run(run) # type: ignore - if run_stream := getattr(cls, "run_stream", None): - cls.run_stream = _trace_agent_run_stream(run_stream) # type: ignore - return cls - - -def _get_agent_run_span( - *, - operation_name: str, - agent: "AgentProtocol", - system: str, - thread: "AgentThread | None", - **kwargs: Any, -) -> Span: - """Start a text or chat completion span for a given model. - - Note that `start_span` doesn't make the span the current span. - Use `use_span` to make it the current span as a context manager. - - Should follow: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/#invoke-agent-span - """ - span = tracer.start_span(f"{operation_name} {agent.display_name}") - - # Set attributes on the span - span.set_attributes({ - GenAIAttributes.OPERATION.value: operation_name, - GenAIAttributes.SYSTEM.value: system, - GenAIAttributes.CHOICE_COUNT.value: 1, - GenAIAttributes.AGENT_ID.value: agent.id, - }) - if agent.name: - span.set_attribute(GenAIAttributes.AGENT_NAME.value, agent.name) - if agent.description: - span.set_attribute(GenAIAttributes.AGENT_DESCRIPTION.value, agent.description) - if thread and thread.service_thread_id: - span.set_attribute(GenAIAttributes.CONVERSATION_ID.value, thread.service_thread_id) - if "model" in kwargs: - span.set_attribute(GenAIAttributes.MODEL.value, kwargs["model"]) - if "seed" in kwargs: - span.set_attribute(GenAIAttributes.SEED.value, kwargs["seed"]) - if "frequency_penalty" in kwargs: - span.set_attribute(GenAIAttributes.FREQUENCY_PENALTY.value, kwargs["frequency_penalty"]) - if "presence_penalty" in kwargs: - span.set_attribute(GenAIAttributes.PRESENCE_PENALTY.value, kwargs["presence_penalty"]) - if "max_tokens" in kwargs: - span.set_attribute(GenAIAttributes.MAX_TOKENS.value, kwargs["max_tokens"]) - if "stop" in kwargs: - span.set_attribute(GenAIAttributes.STOP_SEQUENCES.value, kwargs["stop"]) - if "temperature" in kwargs: - span.set_attribute(GenAIAttributes.TEMPERATURE.value, kwargs["temperature"]) - if "top_p" in kwargs: - span.set_attribute(GenAIAttributes.TOP_P.value, kwargs["top_p"]) - if "top_k" in kwargs: - span.set_attribute(GenAIAttributes.TOP_K.value, kwargs["top_k"]) - if "encoding_formats" in kwargs: - span.set_attribute(GenAIAttributes.ENCODING_FORMATS.value, kwargs["encoding_formats"]) - return span - - -def _set_agent_run_input( - system: str, - messages: "str | ChatMessage | list[str] | list[ChatMessage] | list[str | ChatMessage] | None" = None, -) -> None: - """Set the input for a chat response. - - The logs will be associated to the current span. - """ - if messages and MODEL_DIAGNOSTICS_SETTINGS.SENSITIVE_EVENTS_ENABLED: - if not isinstance(messages, list): - messages = [messages] - for idx, message in enumerate(messages): - if isinstance(message, str): - logger.info( - message, - extra={ - # assume user message - GenAIAttributes.EVENT_NAME.value: GenAIAttributes.USER_MESSAGE.value, - GenAIAttributes.SYSTEM.value: system, - ChatMessageListTimestampFilter.INDEX_KEY: idx, - }, - ) else: - logger.info( - message.model_dump_json(exclude_none=True), - extra={ - GenAIAttributes.EVENT_NAME.value: ROLE_EVENT_MAP.get(message.role.value), - GenAIAttributes.SYSTEM.value: system, - ChatMessageListTimestampFilter.INDEX_KEY: idx, - }, - ) + response = AgentRunResponse.from_agent_run_response_updates(all_updates) + attributes = _get_response_attributes(attributes, response) + _capture_response(span=span, attributes=attributes) + if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=response.messages, + output=True, + ) + + return trace_run_streaming -def _set_agent_run_output( - current_span: Span, - response: "AgentRunResponse", - model_provider: str, -) -> None: - """Set the agent response for a given span.""" - first_completion = response.messages[0] +def use_agent_telemetry( + agent: type[TAgent], +) -> type[TAgent]: + """Class decorator that enables telemetry for an agent.""" + provider_name = str(getattr(agent, "AGENT_SYSTEM_NAME", "Unknown")) + try: + agent.run = _trace_agent_run(agent.run, provider_name) # type: ignore + except AttributeError as exc: + raise AgentInitializationError(f"The agent {agent.__name__} does not have a run method.", exc) from exc + try: + agent.run_stream = _trace_agent_run_stream(agent.run_stream, provider_name) # type: ignore + except AttributeError as exc: + raise AgentInitializationError(f"The agent {agent.__name__} does not have a run_stream method.", exc) from exc + setattr(agent, OPEN_TELEMETRY_AGENT_MARKER, True) + return agent - # Set the response ID - response_id = ( - first_completion.additional_properties.get("id") if first_completion.additional_properties is not None else None + +# region Otel Helpers + + +def get_function_span( + function: "AIFunction[Any, Any]", + tool_call_id: str | None = None, +) -> "_AgnosticContextManager[Span]": + """Starts a span for the given function. + + Args: + function: The function for which to start the span. + tool_call_id: The id of the tool_call that was requested. + + Returns: + trace.Span: The started span as a context manager. + """ + attributes: dict[str, str] = { + OtelAttr.OPERATION: OtelAttr.TOOL_EXECUTION_OPERATION, + OtelAttr.TOOL_NAME: function.name, + OtelAttr.TOOL_CALL_ID: tool_call_id or "unknown", + OtelAttr.TOOL_TYPE: "function", + } + if function.description: + attributes[OtelAttr.TOOL_DESCRIPTION] = function.description + + return tracer.start_as_current_span( + name=f"{OtelAttr.TOOL_EXECUTION_OPERATION} {function.name}", + attributes=attributes, + set_status_on_exception=False, + end_on_exit=True, + record_exception=False, ) - if response_id: - current_span.set_attribute(GenAIAttributes.RESPONSE_ID.value, response_id) - # Set the finish reason - finish_reason = getattr(response.raw_representation, "finish_reason", None) if response.raw_representation else None + +@contextmanager +def _get_span( + attributes: dict[str, Any], + span_name_attribute: str, +) -> Generator[Span, Any, Any]: + """Start a span for a agent run.""" + span = tracer.start_span(f"{attributes[OtelAttr.OPERATION]} {attributes[span_name_attribute]}") + span.set_attributes(attributes) + with use_span( + span=span, + end_on_exit=True, + record_exception=False, + set_status_on_exception=False, + ) as current_span: + yield current_span + + +def _get_span_attributes(**kwargs: Any) -> dict[str, Any]: + """Get the span attributes from a kwargs dictionary.""" + attributes: dict[str, Any] = {} + if operation_name := kwargs.get("operation_name"): + attributes[OtelAttr.OPERATION] = operation_name + if choice_count := kwargs.get("choice_count", 1): + attributes[OtelAttr.CHOICE_COUNT] = choice_count + if operation_name := kwargs.get("operation_name"): + attributes[OtelAttr.OPERATION] = operation_name + if system_name := kwargs.get("system_name"): + attributes[SpanAttributes.LLM_SYSTEM] = system_name + if provider_name := kwargs.get("provider_name"): + attributes[OtelAttr.PROVIDER_NAME] = provider_name + attributes[SpanAttributes.LLM_REQUEST_MODEL] = kwargs.get("model_id", "unknown") + if service_url := kwargs.get("service_url"): + attributes[OtelAttr.ADDRESS] = service_url + if conversation_id := kwargs.get("conversation_id"): + attributes[OtelAttr.CONVERSATION_ID] = conversation_id + if seed := kwargs.get("seed"): + attributes[OtelAttr.SEED] = seed + if frequency_penalty := kwargs.get("frequency_penalty"): + attributes[OtelAttr.FREQUENCY_PENALTY] = frequency_penalty + if max_tokens := kwargs.get("max_tokens"): + attributes[SpanAttributes.LLM_REQUEST_MAX_TOKENS] = max_tokens + if stop := kwargs.get("stop"): + attributes[OtelAttr.STOP_SEQUENCES] = stop + if temperature := kwargs.get("temperature"): + attributes[SpanAttributes.LLM_REQUEST_TEMPERATURE] = temperature + if top_p := kwargs.get("top_p"): + attributes[SpanAttributes.LLM_REQUEST_TOP_P] = top_p + if presence_penalty := kwargs.get("presence_penalty"): + attributes[OtelAttr.PRESENCE_PENALTY] = presence_penalty + if top_k := kwargs.get("top_k"): + attributes[OtelAttr.TOP_K] = top_k + if encoding_formats := kwargs.get("encoding_formats"): + attributes[OtelAttr.ENCODING_FORMATS] = json.dumps( + encoding_formats if isinstance(encoding_formats, list) else [encoding_formats] + ) + if error := kwargs.get("error"): + attributes[OtelAttr.ERROR_TYPE] = type(error).__name__ + # agent attributes + if agent_id := kwargs.get("agent_id"): + attributes[OtelAttr.AGENT_ID] = agent_id + if agent_name := kwargs.get("agent_name"): + attributes[OtelAttr.AGENT_NAME] = agent_name + if agent_description := kwargs.get("agent_description"): + attributes[OtelAttr.AGENT_DESCRIPTION] = agent_description + if thread_id := kwargs.get("thread_id"): + # override if thread is set + attributes[OtelAttr.CONVERSATION_ID] = thread_id + return attributes + + +def _capture_exception(span: Span, exception: Exception, timestamp: int | None = None) -> None: + """Set an error for spans.""" + span.set_attribute(OtelAttr.ERROR_TYPE, type(exception).__name__) + span.record_exception(exception=exception, timestamp=timestamp) + span.set_status(status=StatusCode.ERROR, description=repr(exception)) + + +def _capture_messages( + span: Span, + provider_name: str, + messages: "str | ChatMessage | list[str] | list[ChatMessage]", + system_instructions: str | list[str] | None = None, + output: bool = False, + finish_reason: "FinishReason | None" = None, +) -> None: + """Log messages with extra information.""" + from ._clients import prepare_messages + + prepped = prepare_messages(messages) + for index, message in enumerate(prepped): + logger.info( + message.model_dump_json(exclude_none=True), + extra={ + OtelAttr.EVENT_NAME: OtelAttr.CHOICE if output else ROLE_EVENT_MAP.get(message.role.value), + OtelAttr.PROVIDER_NAME: provider_name, + ChatMessageListTimestampFilter.INDEX_KEY: index, + }, + ) + otel_messages = [_to_otel_message(message) for message in prepped] if finish_reason: - current_span.set_attribute(GenAIAttributes.FINISH_REASONS.value, [finish_reason.value]) + otel_messages[-1]["finish_reason"] = FINISH_REASON_MAP[finish_reason.value] + span.set_attribute(OtelAttr.OUTPUT_MESSAGES if output else OtelAttr.INPUT_MESSAGES, json.dumps(otel_messages)) + if system_instructions: + if not isinstance(system_instructions, list): + system_instructions = [system_instructions] + otel_sys_instructions = [{"type": "text", "content": instruction} for instruction in system_instructions] + span.set_attribute(OtelAttr.SYSTEM_INSTRUCTIONS, json.dumps(otel_sys_instructions)) - # Set usage attributes - usage = response.usage_details - if usage: + +def _to_otel_message(message: "ChatMessage") -> dict[str, Any]: + """Create a otel representation of a message.""" + return {"role": message.role.value, "parts": [_to_otel_part(content) for content in message.contents]} + + +def _to_otel_part(content: "Contents") -> dict[str, Any] | None: + """Create a otel representation of a Content.""" + match content.type: + case "text": + return {"type": "text", "content": content.text} + case "function_call": + return {"type": "tool_call", "id": content.call_id, "name": content.name, "arguments": content.arguments} + case "function_result": + return {"type": "tool_call_response", "id": content.call_id, "response": content.result} + case _: + # GenericPart in otel output messages json spec. + # just required type, and arbitrary other fields. + return content.model_dump(exclude_none=True) + return None + + +def _get_response_attributes( + attributes: dict[str, Any], + response: "ChatResponse | AgentRunResponse", + duration: float | None = None, +) -> dict[str, Any]: + """Get the response attributes from a response.""" + if response.response_id: + attributes[OtelAttr.RESPONSE_ID] = response.response_id + finish_reason = getattr(response, "finish_reason", None) + if not finish_reason: + finish_reason = ( + getattr(response.raw_representation, "finish_reason", None) if response.raw_representation else None + ) + if finish_reason: + attributes[OtelAttr.FINISH_REASONS] = json.dumps([finish_reason.value]) + if ai_model_id := getattr(response, "ai_model_id", None): + attributes[SpanAttributes.LLM_RESPONSE_MODEL] = ai_model_id + if usage := response.usage_details: if usage.input_token_count: - current_span.set_attribute(GenAIAttributes.INPUT_TOKENS.value, usage.input_token_count) + attributes[OtelAttr.INPUT_TOKENS] = usage.input_token_count if usage.output_token_count: - current_span.set_attribute(GenAIAttributes.OUTPUT_TOKENS.value, usage.output_token_count) + attributes[OtelAttr.OUTPUT_TOKENS] = usage.output_token_count + if duration: + attributes[Meters.LLM_OPERATION_DURATION] = duration + return attributes - # Set the completion event - if MODEL_DIAGNOSTICS_SETTINGS.SENSITIVE_EVENTS_ENABLED: - for msg in response.messages: - full_response: dict[str, Any] = { - "message": msg.model_dump(exclude_none=True), - } - full_response["index"] = response.response_id - logger.info( - json.dumps(full_response), - extra={ - GenAIAttributes.EVENT_NAME.value: GenAIAttributes.CHOICE.value, - GenAIAttributes.SYSTEM.value: model_provider, - }, - ) + +GEN_AI_METRIC_ATTRIBUTES = ( + OtelAttr.OPERATION, + OtelAttr.PROVIDER_NAME, + SpanAttributes.LLM_REQUEST_MODEL, + SpanAttributes.LLM_RESPONSE_MODEL, + OtelAttr.ADDRESS, + OtelAttr.PORT, +) + + +def _capture_response( + span: Span, + attributes: dict[str, Any], + operation_duration_histogram: "Histogram | None" = None, + token_usage_histogram: "Histogram | None" = None, +) -> None: + """Set the response for a given span.""" + span.set_attributes(attributes) + attrs: dict[str, Any] = {k: v for k, v in attributes.items() if k in GEN_AI_METRIC_ATTRIBUTES} + if token_usage_histogram and (input_tokens := attributes.get(OtelAttr.INPUT_TOKENS)): + token_usage_histogram.record( + input_tokens, attributes={**attrs, SpanAttributes.LLM_TOKEN_TYPE: OtelAttr.T_TYPE_INPUT} + ) + if token_usage_histogram and (output_tokens := attributes.get(OtelAttr.OUTPUT_TOKENS)): + token_usage_histogram.record(output_tokens, {**attrs, SpanAttributes.LLM_TOKEN_TYPE: OtelAttr.T_TYPE_OUTPUT}) + if operation_duration_histogram and (duration := attributes.get(Meters.LLM_OPERATION_DURATION)): + if OtelAttr.ERROR_TYPE in attributes: + attrs[OtelAttr.ERROR_TYPE] = attributes[OtelAttr.ERROR_TYPE] + operation_duration_histogram.record(duration, attributes=attrs) diff --git a/python/packages/main/pyproject.toml b/python/packages/main/pyproject.toml index b8fcf2f966..28746c286a 100644 --- a/python/packages/main/pyproject.toml +++ b/python/packages/main/pyproject.toml @@ -30,6 +30,9 @@ dependencies = [ "opentelemetry-api ~= 1.24", "opentelemetry-sdk ~= 1.24", "mcp>=1.12", + "azure-monitor-opentelemetry>=1.7.0", + "azure-monitor-opentelemetry-exporter>=1.0.0b41", + "opentelemetry-exporter-otlp-proto-grpc>=1.36.0", ] [project.optional-dependencies] diff --git a/python/packages/main/tests/main/conftest.py b/python/packages/main/tests/main/conftest.py index 7801989167..3391a3266e 100644 --- a/python/packages/main/tests/main/conftest.py +++ b/python/packages/main/tests/main/conftest.py @@ -1,11 +1,64 @@ # Copyright (c) Microsoft. All rights reserved. -from typing import Any -from pydantic import BaseModel +import asyncio +import logging +import sys +from collections.abc import AsyncIterable, MutableSequence +from typing import Any +from unittest.mock import patch +from uuid import uuid4 + +from pydantic import BaseModel, Field from pytest import fixture -from agent_framework import ChatMessage, ToolProtocol, ai_function -from agent_framework.telemetry import ModelDiagnosticSettings +from agent_framework import ( + AgentProtocol, + AgentRunResponse, + AgentRunResponseUpdate, + AgentThread, + BaseChatClient, + ChatMessage, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + Role, + TextContent, + ToolProtocol, + ai_function, + use_function_invocation, +) +from agent_framework.telemetry import OtelSettings, setup_telemetry + +if sys.version_info >= (3, 12): + from typing import override # type: ignore +else: + from typing_extensions import override # type: ignore[import] +# region Chat History + +logger = logging.getLogger(__name__) + + +@fixture +def enable_otel(request: Any) -> bool: + """Fixture that returns a boolean indicating if Otel is enabled.""" + return request.param if hasattr(request, "param") else True + + +@fixture +def enable_sensitive_data(request: Any) -> bool: + """Fixture that returns a boolean indicating if sensitive data is enabled.""" + return request.param if hasattr(request, "param") else False + + +@fixture +def otel_settings(enable_otel: bool, enable_sensitive_data: bool) -> OtelSettings: + """Fixture to set environment variables for OtelSettings.""" + + from agent_framework.telemetry import OTEL_SETTINGS + + setup_telemetry(enable_otel=enable_otel, enable_sensitive_data=enable_sensitive_data) + + return OTEL_SETTINGS @fixture(scope="function") @@ -13,13 +66,16 @@ def chat_history() -> list[ChatMessage]: return [] +# region Tools + + @fixture def ai_tool() -> ToolProtocol: """Returns a generic ToolProtocol.""" class GenericTool(BaseModel): name: str - description: str | None = None + description: str additional_properties: dict[str, Any] | None = None def parameters(self) -> dict[str, Any]: @@ -43,17 +99,165 @@ def ai_function_tool() -> ToolProtocol: return simple_function +# region Chat Clients +class MockChatClient: + """Simple implementation of a chat client.""" + + def __init__(self) -> None: + self.additional_properties: dict[str, Any] = {} + + async def get_response( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage], + **kwargs: Any, + ) -> ChatResponse: + logger.debug(f"Running custom chat client, with: {messages=}, {kwargs=}") + return ChatResponse(messages=ChatMessage(role="assistant", text="test response")) + + async def get_streaming_response( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage], + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + logger.debug(f"Running custom chat client stream, with: {messages=}, {kwargs=}") + yield ChatResponseUpdate(text=TextContent(text="test streaming response "), role="assistant") + yield ChatResponseUpdate(contents=[TextContent(text="another update")], role="assistant") + + +class MockBaseChatClient(BaseChatClient): + """Mock implementation of the BaseChatClient.""" + + run_responses: list[ChatResponse] = Field(default_factory=list) + streaming_responses: list[list[ChatResponseUpdate]] = Field(default_factory=list) + + @override + async def _inner_get_response( + self, + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> ChatResponse: + """Send a chat request to the AI service. + + Args: + messages: The chat messages to send. + chat_options: The options for the request. + kwargs: Any additional keyword arguments. + + Returns: + The chat response contents representing the response(s). + """ + logger.debug(f"Running base chat client inner, with: {messages=}, {chat_options=}, {kwargs=}") + if not self.run_responses: + return ChatResponse(messages=ChatMessage(role="assistant", text=f"test response - {messages[0].text}")) + if chat_options.tool_choice == "none": + return ChatResponse( + messages=ChatMessage(role="assistant", text="I broke out of the function invocation loop...") + ) + return self.run_responses.pop(0) + + @override + async def _inner_get_streaming_response( + self, + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + logger.debug(f"Running base chat client inner stream, with: {messages=}, {chat_options=}, {kwargs=}") + if not self.streaming_responses: + yield ChatResponseUpdate(text=f"update - {messages[0].text}", role="assistant") + return + if chat_options.tool_choice == "none": + yield ChatResponseUpdate(text="I broke out of the function invocation loop...", role="assistant") + return + response = self.streaming_responses.pop(0) + for update in response: + yield update + await asyncio.sleep(0) + + @fixture -def model_diagnostic_settings(monkeypatch, request) -> ModelDiagnosticSettings: - """Fixture to set environment variables for ModelDiagnosticSettings.""" - enabled = getattr(request, "param", (None, None))[0] - sensitive = getattr(request, "param", (None, None))[1] - if enabled is None: - monkeypatch.delenv("AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS", raising=False) - else: - monkeypatch.setenv("AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS", str(enabled).lower()) - if sensitive is None: - monkeypatch.delenv("AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE", raising=False) - else: - monkeypatch.setenv("AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE", str(sensitive).lower()) - return ModelDiagnosticSettings(env_file_path="test.env") +def enable_function_calling(request: Any) -> bool: + return request.param if hasattr(request, "param") else True + + +@fixture +def max_iterations(request: Any) -> int: + return request.param if hasattr(request, "param") else 2 + + +@fixture +def chat_client(enable_function_calling: bool, max_iterations: int) -> MockChatClient: + if enable_function_calling: + with patch("agent_framework._tools.DEFAULT_MAX_ITERATIONS", max_iterations): + return use_function_invocation(MockChatClient)() + return MockChatClient() + + +@fixture +def chat_client_base(enable_function_calling: bool, max_iterations: int) -> MockBaseChatClient: + if enable_function_calling: + with patch("agent_framework._tools.DEFAULT_MAX_ITERATIONS", max_iterations): + return use_function_invocation(MockBaseChatClient)() + return MockBaseChatClient() + + +# region Agents +class MockAgentThread(AgentThread): + pass + + +# Mock Agent implementation for testing +class MockAgent(AgentProtocol): + @property + def id(self) -> str: + return str(uuid4()) + + @property + def name(self) -> str | None: + """Returns the name of the agent.""" + return "Name" + + @property + def display_name(self) -> str: + """Returns the name of the agent.""" + return "Display Name" + + @property + def description(self) -> str | None: + return "Description" + + async def run( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AgentRunResponse: + logger.debug(f"Running mock agent, with: {messages=}, {thread=}, {kwargs=}") + return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("Response")])]) + + async def run_stream( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AsyncIterable[AgentRunResponseUpdate]: + logger.debug(f"Running mock agent stream, with: {messages=}, {thread=}, {kwargs=}") + yield AgentRunResponseUpdate(contents=[TextContent("Response")]) + + def get_new_thread(self) -> AgentThread: + return MockAgentThread() + + +@fixture +def agent_thread() -> AgentThread: + return MockAgentThread() + + +@fixture +def agent() -> AgentProtocol: + return MockAgent() diff --git a/python/packages/main/tests/main/test_agents.py b/python/packages/main/tests/main/test_agents.py index ab48a8ad45..7af3252708 100644 --- a/python/packages/main/tests/main/test_agents.py +++ b/python/packages/main/tests/main/test_agents.py @@ -1,122 +1,27 @@ # Copyright (c) Microsoft. All rights reserved. -from collections.abc import AsyncIterable, MutableSequence -from typing import Any +from collections.abc import AsyncIterable from uuid import uuid4 -from pytest import fixture, raises +from pytest import raises from agent_framework import ( AgentProtocol, AgentRunResponse, AgentRunResponseUpdate, AgentThread, - BaseChatClient, ChatAgent, ChatClientProtocol, ChatMessage, ChatMessageList, - ChatOptions, ChatResponse, - ChatResponseUpdate, + HostedCodeInterpreterTool, Role, TextContent, ) from agent_framework.exceptions import AgentExecutionException -# Mock AgentThread implementation for testing -class MockAgentThread(AgentThread): - pass - - -# Mock Agent implementation for testing -class MockAgent(AgentProtocol): - @property - def id(self) -> str: - return str(uuid4()) - - @property - def name(self) -> str | None: - """Returns the name of the agent.""" - return "Name" - - @property - def display_name(self) -> str: - """Returns the name of the agent.""" - return "Display Name" - - @property - def description(self) -> str | None: - return "Description" - - async def run( - self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, - *, - thread: AgentThread | None = None, - **kwargs: Any, - ) -> AgentRunResponse: - return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("Response")])]) - - async def run_stream( - self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, - *, - thread: AgentThread | None = None, - **kwargs: Any, - ) -> AsyncIterable[AgentRunResponseUpdate]: - yield AgentRunResponseUpdate(contents=[TextContent("Response")]) - - def get_new_thread(self) -> AgentThread: - return MockAgentThread() - - -# Mock ChatClientProtocol implementation for testing -class MockChatClient(BaseChatClient): - _mock_response: ChatResponse | None = None - - def __init__(self, mock_response: ChatResponse | None = None) -> None: - self._mock_response = mock_response - - async def _inner_get_response( - self, - *, - messages: MutableSequence[ChatMessage], - chat_options: ChatOptions, - **kwargs: Any, - ) -> ChatResponse: - return ( - self._mock_response - if self._mock_response - else ChatResponse(messages=ChatMessage(role=Role.ASSISTANT, text="test response")) - ) - - async def _inner_get_streaming_response( - self, - *, - messages: MutableSequence[ChatMessage], - chat_options: ChatOptions, - **kwargs: Any, - ) -> AsyncIterable[ChatResponseUpdate]: - yield ChatResponseUpdate(role=Role.ASSISTANT, text=TextContent(text="test streaming response")) - - -@fixture -def agent_thread() -> AgentThread: - return MockAgentThread() - - -@fixture -def agent() -> AgentProtocol: - return MockAgent() - - -@fixture -def chat_client() -> BaseChatClient: - return MockChatClient() - - def test_agent_thread_type(agent_thread: AgentThread) -> None: assert isinstance(agent_thread, AgentThread) @@ -178,7 +83,7 @@ async def test_chat_client_agent_run_streaming(chat_client: ChatClientProtocol) result = await AgentRunResponse.from_agent_response_generator(agent.run_stream("Hello")) - assert result.text == "test streaming response" + assert result.text == "test streaming response another update" async def test_chat_client_agent_get_new_thread(chat_client: ChatClientProtocol) -> None: @@ -203,14 +108,16 @@ async def test_chat_client_agent_prepare_thread_and_messages(chat_client: ChatCl assert result_messages[1].text == "Test" -async def test_chat_client_agent_update_thread_id() -> None: - chat_client = MockChatClient( - mock_response=ChatResponse( - messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")])], - conversation_id="123", - ) +async def test_chat_client_agent_update_thread_id(chat_client_base: ChatClientProtocol) -> None: + mock_response = ChatResponse( + messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")])], + conversation_id="123", + ) + chat_client_base.run_responses = [mock_response] + agent = ChatAgent( + chat_client=chat_client_base, + tools=HostedCodeInterpreterTool(), ) - agent = ChatAgent(chat_client=chat_client) thread = agent.get_new_thread() result = await agent.run("Hello", thread=thread) @@ -263,15 +170,16 @@ async def test_chat_client_agent_author_name_as_agent_name(chat_client: ChatClie assert result.messages[0].author_name == "TestAgent" -async def test_chat_client_agent_author_name_is_used_from_response() -> None: - chat_client = MockChatClient( - mock_response=ChatResponse( +async def test_chat_client_agent_author_name_is_used_from_response(chat_client_base: ChatClientProtocol) -> None: + chat_client_base.run_responses = [ + ChatResponse( messages=[ ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")], author_name="TestAuthor") ] ) - ) - agent = ChatAgent(chat_client=chat_client) + ] + + agent = ChatAgent(chat_client=chat_client_base, tools=HostedCodeInterpreterTool()) result = await agent.run("Hello") assert result.text == "test response" diff --git a/python/packages/main/tests/main/test_clients.py b/python/packages/main/tests/main/test_clients.py index 5d415bec91..d733362bc9 100644 --- a/python/packages/main/tests/main/test_clients.py +++ b/python/packages/main/tests/main/test_clients.py @@ -1,18 +1,15 @@ # Copyright (c) Microsoft. All rights reserved. -import asyncio import sys -from collections.abc import AsyncIterable, MutableSequence, Sequence +from collections.abc import Sequence from typing import Any -from pydantic import Field from pytest import fixture from agent_framework import ( BaseChatClient, ChatClientProtocol, ChatMessage, - ChatOptions, ChatResponse, ChatResponseUpdate, EmbeddingGenerator, @@ -22,81 +19,12 @@ from agent_framework import ( Role, TextContent, ai_function, - use_tool_calling, ) if sys.version_info >= (3, 12): - from typing import override # type: ignore + pass # type: ignore else: - from typing_extensions import override # type: ignore[import] - - -class MockChatClient: - """Simple implementation of a chat client.""" - - async def get_response( - self, - messages: ChatMessage | Sequence[ChatMessage], - **kwargs: Any, - ) -> ChatResponse: - # Implement the method - - return ChatResponse(messages=ChatMessage(role="assistant", text="test response")) - - async def get_streaming_response( - self, - messages: ChatMessage | Sequence[ChatMessage], - **kwargs: Any, - ) -> AsyncIterable[ChatResponseUpdate]: - # Implement the method - yield ChatResponseUpdate(text=TextContent(text="test streaming response"), role="assistant") - yield ChatResponseUpdate(contents=[TextContent(text="another update")], role="assistant") - - -@use_tool_calling -class MockBaseChatClient(BaseChatClient): - """Mock implementation of the BaseChatClient.""" - - run_responses: list[ChatResponse] = Field(default_factory=list) - streaming_responses: list[list[ChatResponseUpdate]] = Field(default_factory=list) - - @override - async def _inner_get_response( - self, - *, - messages: MutableSequence[ChatMessage], - chat_options: ChatOptions, - **kwargs: Any, - ) -> ChatResponse: - """Send a chat request to the AI service. - - Args: - messages: The chat messages to send. - chat_options: The options for the request. - kwargs: Any additional keyword arguments. - - Returns: - The chat response contents representing the response(s). - """ - if not self.run_responses or chat_options.tool_choice == "none": - return ChatResponse(messages=ChatMessage(role="assistant", text=f"test response - {messages[0].text}")) - return self.run_responses.pop(0) - - @override - async def _inner_get_streaming_response( - self, - *, - messages: MutableSequence[ChatMessage], - chat_options: ChatOptions, - **kwargs: Any, - ) -> AsyncIterable[ChatResponseUpdate]: - if not self.streaming_responses or chat_options.tool_choice == "none": - yield ChatResponseUpdate(text=f"update - {messages[0].text}", role="assistant") - return - response = self.streaming_responses.pop(0) - for update in response: - yield update - await asyncio.sleep(0) + pass # type: ignore[import] class MockEmbeddingGenerator: @@ -114,35 +42,25 @@ class MockEmbeddingGenerator: return embeddings -@fixture -def chat_client() -> MockChatClient: - return MockChatClient() - - -@fixture -def chat_client_base() -> MockBaseChatClient: - return MockBaseChatClient() - - @fixture def embedding_generator() -> MockEmbeddingGenerator: gen: EmbeddingGenerator[str, list[float]] = MockEmbeddingGenerator() return gen -def test_chat_client_type(chat_client: MockChatClient): +def test_chat_client_type(chat_client: ChatClientProtocol): assert isinstance(chat_client, ChatClientProtocol) -async def test_chat_client_get_response(chat_client: MockChatClient): +async def test_chat_client_get_response(chat_client: ChatClientProtocol): response = await chat_client.get_response(ChatMessage(role="user", text="Hello")) assert response.text == "test response" assert response.messages[0].role == Role.ASSISTANT -async def test_chat_client_get_streaming_response(chat_client: MockChatClient): +async def test_chat_client_get_streaming_response(chat_client: ChatClientProtocol): async for update in chat_client.get_streaming_response(ChatMessage(role="user", text="Hello")): - assert update.text == "test streaming response" or update.text == "another update" + assert update.text == "test streaming response " or update.text == "another update" assert update.role == Role.ASSISTANT @@ -158,23 +76,23 @@ async def test_embedding_generator_generate(embedding_generator: MockEmbeddingGe assert len(emb) == 5 -def test_base_client(chat_client_base: MockBaseChatClient): +def test_base_client(chat_client_base: ChatClientProtocol): assert isinstance(chat_client_base, BaseChatClient) assert isinstance(chat_client_base, ChatClientProtocol) -async def test_base_client_get_response(chat_client_base: MockBaseChatClient): +async def test_base_client_get_response(chat_client_base: ChatClientProtocol): response = await chat_client_base.get_response(ChatMessage(role="user", text="Hello")) assert response.messages[0].role == Role.ASSISTANT assert response.messages[0].text == "test response - Hello" -async def test_base_client_get_streaming_response(chat_client_base: MockBaseChatClient): +async def test_base_client_get_streaming_response(chat_client_base: ChatClientProtocol): async for update in chat_client_base.get_streaming_response(ChatMessage(role="user", text="Hello")): assert update.text == "update - Hello" or update.text == "another update" -async def test_base_client_with_function_calling(chat_client_base: MockBaseChatClient): +async def test_base_client_with_function_calling(chat_client_base: ChatClientProtocol): exec_counter = 0 @ai_function(name="test_function") @@ -208,8 +126,7 @@ async def test_base_client_with_function_calling(chat_client_base: MockBaseChatC assert response.messages[2].text == "done" -async def test_base_client_with_function_calling_disabled(chat_client_base: MockBaseChatClient): - chat_client_base.__maximum_iterations_per_request = 0 +async def test_base_client_with_function_calling_resets(chat_client_base: ChatClientProtocol): exec_counter = 0 @ai_function(name="test_function") @@ -225,16 +142,32 @@ async def test_base_client_with_function_calling_disabled(chat_client_base: Mock contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')], ) ), + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="2", name="test_function", arguments='{"arg1": "value1"}')], + ) + ), ChatResponse(messages=ChatMessage(role="assistant", text="done")), ] response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func]) - assert exec_counter == 0 - assert len(response.messages) == 1 + assert exec_counter == 2 + assert len(response.messages) == 5 assert response.messages[0].role == Role.ASSISTANT - assert response.messages[0].text == "test response - hello" + assert response.messages[1].role == Role.TOOL + assert response.messages[2].role == Role.ASSISTANT + assert response.messages[3].role == Role.TOOL + assert response.messages[4].role == Role.ASSISTANT + assert isinstance(response.messages[0].contents[0], FunctionCallContent) + assert isinstance(response.messages[1].contents[0], FunctionResultContent) + assert isinstance(response.messages[2].contents[0], FunctionCallContent) + assert isinstance(response.messages[3].contents[0], FunctionResultContent) + # after these two responses, it would try another regular call, but since max_iterations is 1, it stops and calls + assert isinstance(response.messages[4].contents[0], TextContent) + assert response.text == "I broke out of the function invocation loop..." -async def test_base_client_with_streaming_function_calling(chat_client_base: MockBaseChatClient): +async def test_base_client_with_streaming_function_calling(chat_client_base: ChatClientProtocol): exec_counter = 0 @ai_function(name="test_function") @@ -270,38 +203,3 @@ async def test_base_client_with_streaming_function_calling(chat_client_base: Moc assert updates[2].contents[0].call_id == "1" assert updates[3].text == "Processed value1" assert exec_counter == 1 - - -async def test_base_client_with_streaming_function_calling_disabled(chat_client_base: MockBaseChatClient): - chat_client_base.__maximum_iterations_per_request = 0 - exec_counter = 0 - - @ai_function(name="test_function") - def ai_func(arg1: str) -> str: - nonlocal exec_counter - exec_counter += 1 - return f"Processed {arg1}" - - chat_client_base.streaming_responses = [ - [ - ChatResponseUpdate( - contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1":')], - role="assistant", - ), - ChatResponseUpdate( - contents=[FunctionCallContent(call_id="1", name="test_function", arguments='"value1"}')], - role="assistant", - ), - ], - [ - ChatResponseUpdate( - contents=[TextContent(text="Processed value1")], - role="assistant", - ) - ], - ] - updates = [] - async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]): - updates.append(update) - assert len(updates) == 1 - assert exec_counter == 0 diff --git a/python/packages/main/tests/main/test_telemetry.py b/python/packages/main/tests/main/test_telemetry.py index 8936decfcc..597961488e 100644 --- a/python/packages/main/tests/main/test_telemetry.py +++ b/python/packages/main/tests/main/test_telemetry.py @@ -1,14 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. import logging -from collections.abc import AsyncIterable, MutableSequence +from collections.abc import MutableSequence from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest +from opentelemetry.semconv_ai import SpanAttributes from opentelemetry.trace import StatusCode from agent_framework import ( + AgentProtocol, + AgentRunResponse, + AgentThread, + BaseChatClient, ChatMessage, ChatOptions, ChatResponse, @@ -16,15 +21,19 @@ from agent_framework import ( Role, UsageDetails, ) +from agent_framework.exceptions import AgentInitializationError, ChatClientInitializationError from agent_framework.telemetry import ( AGENT_FRAMEWORK_USER_AGENT, + OPEN_TELEMETRY_AGENT_MARKER, + OPEN_TELEMETRY_CHAT_CLIENT_MARKER, ROLE_EVENT_MAP, - TELEMETRY_DISABLED_ENV_VAR, USER_AGENT_KEY, + USER_AGENT_TELEMETRY_DISABLED_ENV_VAR, ChatMessageListTimestampFilter, - GenAIAttributes, + OtelAttr, + get_function_span, prepend_agent_framework_to_user_agent, - start_as_current_span, + use_agent_telemetry, use_telemetry, ) @@ -33,7 +42,7 @@ from agent_framework.telemetry import ( def test_telemetry_disabled_env_var(): """Test that the telemetry disabled environment variable is correctly defined.""" - assert TELEMETRY_DISABLED_ENV_VAR == "AZURE_TELEMETRY_DISABLED" + assert USER_AGENT_TELEMETRY_DISABLED_ENV_VAR == "AGENT_FRAMEWORK_USER_AGENT_DISABLED" def test_user_agent_key(): @@ -78,20 +87,20 @@ def test_app_info_when_telemetry_disabled(): def test_role_event_map(): """Test that ROLE_EVENT_MAP contains expected mappings.""" - assert ROLE_EVENT_MAP["system"] == GenAIAttributes.SYSTEM_MESSAGE.value - assert ROLE_EVENT_MAP["user"] == GenAIAttributes.USER_MESSAGE.value - assert ROLE_EVENT_MAP["assistant"] == GenAIAttributes.ASSISTANT_MESSAGE.value - assert ROLE_EVENT_MAP["tool"] == GenAIAttributes.TOOL_MESSAGE.value + assert ROLE_EVENT_MAP["system"] == OtelAttr.SYSTEM_MESSAGE + assert ROLE_EVENT_MAP["user"] == OtelAttr.USER_MESSAGE + assert ROLE_EVENT_MAP["assistant"] == OtelAttr.ASSISTANT_MESSAGE + assert ROLE_EVENT_MAP["tool"] == OtelAttr.TOOL_MESSAGE def test_enum_values(): - """Test that GenAIAttributes enum has expected values.""" - assert GenAIAttributes.OPERATION.value == "gen_ai.operation.name" - assert GenAIAttributes.SYSTEM.value == "gen_ai.system" - assert GenAIAttributes.MODEL.value == "gen_ai.request.model" - assert GenAIAttributes.CHAT_COMPLETION_OPERATION.value == "chat" - assert GenAIAttributes.TOOL_EXECUTION_OPERATION.value == "execute_tool" - assert GenAIAttributes.AGENT_INVOKE_OPERATION.value == "invoke_agent" + """Test that OtelAttr enum has expected values.""" + assert OtelAttr.OPERATION == "gen_ai.operation.name" + assert SpanAttributes.LLM_SYSTEM == "gen_ai.system" + assert SpanAttributes.LLM_REQUEST_MODEL == "gen_ai.request.model" + assert OtelAttr.CHAT_COMPLETION_OPERATION == "chat" + assert OtelAttr.TOOL_EXECUTION_OPERATION == "execute_tool" + assert OtelAttr.AGENT_INVOKE_OPERATION == "invoke_agent" # region Test prepend_agent_framework_to_user_agent @@ -135,47 +144,6 @@ def test_modifies_original_dict(): assert "User-Agent" in headers -# region ModelDiagnosticSettings tests - - -@pytest.mark.parametrize("model_diagnostic_settings", [(None, None)], indirect=True) -def test_default_values(model_diagnostic_settings): - """Test default values for ModelDiagnosticSettings.""" - assert not model_diagnostic_settings.ENABLED - assert not model_diagnostic_settings.SENSITIVE_EVENTS_ENABLED - - -@pytest.mark.parametrize("model_diagnostic_settings", [(False, False)], indirect=True) -def test_disabled(model_diagnostic_settings): - """Test default values for ModelDiagnosticSettings.""" - assert not model_diagnostic_settings.ENABLED - assert not model_diagnostic_settings.SENSITIVE_EVENTS_ENABLED - - -@pytest.mark.parametrize("model_diagnostic_settings", [(True, False)], indirect=True) -def test_non_sensitive_events_enabled(model_diagnostic_settings): - """Test loading model_diagnostic_settings from environment variables.""" - assert model_diagnostic_settings.ENABLED - assert not model_diagnostic_settings.SENSITIVE_EVENTS_ENABLED - - -@pytest.mark.parametrize("model_diagnostic_settings", [(True, True)], indirect=True) -def test_sensitive_events_enabled(model_diagnostic_settings): - """Test loading model_diagnostic_settings from environment variables.""" - assert model_diagnostic_settings.ENABLED - assert model_diagnostic_settings.SENSITIVE_EVENTS_ENABLED - - -@pytest.mark.parametrize("model_diagnostic_settings", [(False, True)], indirect=True) -def test_sensitive_events_enabled_only(model_diagnostic_settings): - """Test loading sensitive events setting from environment. - - But when sensitive events are enabled, diagnostics are also enabled. - """ - assert model_diagnostic_settings.ENABLED - assert model_diagnostic_settings.SENSITIVE_EVENTS_ENABLED - - # region Test ChatMessageListTimestampFilter @@ -213,88 +181,74 @@ def test_filter_with_index_key(): def test_index_key_constant(): """Test that INDEX_KEY constant is correctly defined.""" - assert ChatMessageListTimestampFilter.INDEX_KEY == "CHAT_MESSAGE_INDEX" + assert ChatMessageListTimestampFilter.INDEX_KEY == "chat_message_index" -# region Test start_as_current_span +# region Test get_function_span def test_start_span_basic(): """Test starting a span with basic function info.""" mock_tracer = Mock() - mock_span = Mock() - mock_tracer.start_as_current_span.return_value = mock_span + with patch("agent_framework.telemetry.tracer", mock_tracer): + mock_span = Mock() + mock_tracer.start_as_current_span.return_value = mock_span - # Create a mock function - mock_function = Mock() - mock_function.name = "test_function" - mock_function.description = "Test function description" + # Create a mock function + mock_function = Mock() + mock_function.name = "test_function" + mock_function.description = "Test function description" - result = start_as_current_span(mock_tracer, mock_function) + result = get_function_span(mock_function) - assert result == mock_span - mock_tracer.start_as_current_span.assert_called_once() + assert result == mock_span + mock_tracer.start_as_current_span.assert_called_once() - call_args = mock_tracer.start_as_current_span.call_args - assert call_args[0][0] == "execute_tool test_function" + call_args = mock_tracer.start_as_current_span.call_args + assert call_args[1]["name"] == "execute_tool test_function" - attributes = call_args[1]["attributes"] - assert attributes[GenAIAttributes.OPERATION.value] == GenAIAttributes.TOOL_EXECUTION_OPERATION.value - assert attributes[GenAIAttributes.TOOL_NAME.value] == "test_function" - assert attributes[GenAIAttributes.TOOL_DESCRIPTION.value] == "Test function description" + attributes = call_args[1]["attributes"] + assert attributes[OtelAttr.OPERATION.value] == OtelAttr.TOOL_EXECUTION_OPERATION + assert attributes[OtelAttr.TOOL_NAME] == "test_function" + assert attributes[OtelAttr.TOOL_DESCRIPTION] == "Test function description" -def test_start_span_with_metadata(): - """Test starting a span with metadata containing tool_call_id.""" +def test_start_span_with_tool_call_id(): + """Test starting a span with tool_call_id.""" mock_tracer = Mock() - mock_span = Mock() - mock_tracer.start_as_current_span.return_value = mock_span + with patch("agent_framework.telemetry.tracer", mock_tracer): + mock_span = Mock() + mock_tracer.start_as_current_span.return_value = mock_span - mock_function = Mock() - mock_function.name = "test_function" - mock_function.description = "Test function" + mock_function = Mock() + mock_function.name = "test_function" + mock_function.description = "Test function" - metadata = {"tool_call_id": "test_call_123"} + tool_call_id = "test_call_123" - _ = start_as_current_span(mock_tracer, mock_function, metadata) + _ = get_function_span(mock_function, tool_call_id) - call_args = mock_tracer.start_as_current_span.call_args - attributes = call_args[1]["attributes"] - assert attributes[GenAIAttributes.TOOL_CALL_ID.value] == "test_call_123" + call_args = mock_tracer.start_as_current_span.call_args + attributes = call_args[1]["attributes"] + assert attributes[OtelAttr.TOOL_CALL_ID] == "test_call_123" def test_start_span_without_description(): """Test starting a span when function has no description.""" mock_tracer = Mock() - mock_span = Mock() - mock_tracer.start_as_current_span.return_value = mock_span + with patch("agent_framework.telemetry.tracer", mock_tracer): + mock_span = Mock() + mock_tracer.start_as_current_span.return_value = mock_span - mock_function = Mock() - mock_function.name = "test_function" - mock_function.description = None + mock_function = Mock() + mock_function.name = "test_function" + mock_function.description = None - start_as_current_span(mock_tracer, mock_function) + get_function_span(mock_function) - call_args = mock_tracer.start_as_current_span.call_args - attributes = call_args[1]["attributes"] - assert GenAIAttributes.TOOL_DESCRIPTION.value not in attributes - - -def test_start_span_empty_metadata(): - """Test starting a span with empty metadata.""" - mock_tracer = Mock() - mock_span = Mock() - mock_tracer.start_as_current_span.return_value = mock_span - - mock_function = Mock() - mock_function.name = "test_function" - mock_function.description = "Test function" - - start_as_current_span(mock_tracer, mock_function, {}) - - call_args = mock_tracer.start_as_current_span.call_args - attributes = call_args[1]["attributes"] - assert GenAIAttributes.TOOL_CALL_ID.value not in attributes + call_args = mock_tracer.start_as_current_span.call_args + attributes = call_args[1]["attributes"] + assert OtelAttr.TOOL_DESCRIPTION not in attributes # region Test use_telemetry decorator @@ -305,12 +259,10 @@ def test_decorator_with_valid_class(): # Create a mock class with the required methods class MockChatClient: - MODEL_PROVIDER_NAME = "test_provider" - - async def _inner_get_response(self, *, messages, chat_options, **kwargs): + async def get_response(self, messages, **kwargs): return Mock() - async def _inner_get_streaming_response(self, *, messages, chat_options, **kwargs): + async def get_streaming_response(self, messages, **kwargs): async def gen(): yield Mock() @@ -318,39 +270,31 @@ def test_decorator_with_valid_class(): # Apply the decorator decorated_class = use_telemetry(MockChatClient) - - # Check that the methods were wrapped - assert hasattr(decorated_class._inner_get_response, "__model_diagnostics_chat_client__") - assert hasattr(decorated_class._inner_get_streaming_response, "__model_diagnostics_streaming_chat_completion__") + assert hasattr(decorated_class, OPEN_TELEMETRY_CHAT_CLIENT_MARKER) def test_decorator_with_missing_methods(): """Test that decorator handles classes missing required methods gracefully.""" class MockChatClient: - MODEL_PROVIDER_NAME = "test_provider" + OTEL_PROVIDER_NAME = "test_provider" # Apply the decorator - should not raise an error - decorated_class = use_telemetry(MockChatClient) - - # Class should be returned unchanged - assert decorated_class is MockChatClient + with pytest.raises(ChatClientInitializationError): + use_telemetry(MockChatClient) def test_decorator_with_partial_methods(): """Test decorator when only one method is present.""" class MockChatClient: - MODEL_PROVIDER_NAME = "test_provider" + OTEL_PROVIDER_NAME = "test_provider" - async def _inner_get_response(self, *, messages, chat_options, **kwargs): + async def get_response(self, messages, **kwargs): return Mock() - decorated_class = use_telemetry(MockChatClient) - - # Only the present method should be wrapped - assert hasattr(decorated_class._inner_get_response, "__model_diagnostics_chat_client__") - assert not hasattr(decorated_class, "_inner_get_streaming_response") + with pytest.raises(ChatClientInitializationError): + use_telemetry(MockChatClient) # region Test telemetry decorator with mock client @@ -360,12 +304,7 @@ def test_decorator_with_partial_methods(): def mock_chat_client(): """Create a mock chat client for testing.""" - class MockChatClient: - MODEL_PROVIDER_NAME = "test_provider" - - def __init__(self): - self.ai_model_id = "test-model" - + class MockChatClient(BaseChatClient): def service_url(self): return "https://test.example.com" @@ -384,223 +323,77 @@ def mock_chat_client(): yield ChatResponseUpdate(text="Hello", role=Role.ASSISTANT) yield ChatResponseUpdate(text=" world", role=Role.ASSISTANT) - return MockChatClient() + return MockChatClient -@pytest.mark.parametrize("model_diagnostic_settings", [(False, False)], indirect=True) -async def test_telemetry_disabled_bypasses_instrumentation(mock_chat_client, model_diagnostic_settings): - """Test that when diagnostics are disabled, telemetry is bypassed.""" - decorated_class = use_telemetry(type(mock_chat_client)) - client = decorated_class() - - messages = [ChatMessage(role=Role.USER, text="Test message")] - chat_options = ChatOptions() - - with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry.use_span") as mock_use_span, - ): - # This should not create any spans - response = await client._inner_get_response(messages=messages, chat_options=chat_options) - assert response is not None - mock_use_span.assert_not_called() - - -@pytest.mark.parametrize("model_diagnostic_settings", [(True, True)], indirect=True) -async def test_instrumentation_enabled(mock_chat_client, model_diagnostic_settings): +@pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True) +async def test_instrumentation_enabled(mock_chat_client, otel_settings): """Test that when diagnostics are enabled, telemetry is applied.""" - decorated_class = use_telemetry(type(mock_chat_client)) - client = decorated_class() + client = use_telemetry(mock_chat_client)() messages = [ChatMessage(role=Role.USER, text="Test message")] chat_options = ChatOptions() with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry.use_span") as mock_use_span, - patch("agent_framework.telemetry.logger") as mock_logger, + patch("agent_framework.telemetry._get_span") as mock_response_span, + patch("agent_framework.telemetry._capture_messages") as mock_log_messages, ): - response = await client._inner_get_response(messages=messages, chat_options=chat_options) + response = await client.get_response(messages=messages, chat_options=chat_options) assert response is not None - mock_use_span.assert_called_once() - # Check that logger.info was called (telemetry logs input/output) - assert mock_logger.info.call_count == 2 + mock_response_span.assert_called_once() + + # Check that log messages was called only if sensitive events are enabled + assert mock_log_messages.call_count == (2 if otel_settings.enable_sensitive_data else 0) -@pytest.mark.parametrize("model_diagnostic_settings", [(True, False)], indirect=True) -async def test_streaming_response_with_diagnostics_enabled_via_decorator(mock_chat_client, model_diagnostic_settings): +@pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True) +async def test_streaming_response_with_otel(mock_chat_client, otel_settings): """Test streaming telemetry through the use_telemetry decorator.""" - decorated_class = use_telemetry(type(mock_chat_client)) - client = decorated_class() + client = use_telemetry(mock_chat_client)() messages = [ChatMessage(role=Role.USER, text="Test")] chat_options = ChatOptions() with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry.use_span") as mock_use_span, - patch("agent_framework.telemetry._get_chat_response_span") as mock_get_span, - patch("agent_framework.telemetry._set_chat_response_input") as mock_set_input, - patch("agent_framework.telemetry._set_chat_response_output") as mock_set_output, + patch("agent_framework.telemetry._get_span") as mock_response_span, + patch("agent_framework.telemetry._capture_messages") as mock_log_messages, + patch("agent_framework.telemetry._capture_response") as mock_set_output, ): - mock_span = Mock() - mock_use_span.return_value.__enter__.return_value = mock_span - mock_use_span.return_value.__exit__.return_value = None - - # We can't easily mock ChatResponse.from_chat_response_updates since it's imported locally, - # but we can verify telemetry calls were made - # Collect all yielded updates updates = [] - async for update in client._inner_get_streaming_response(messages=messages, chat_options=chat_options): + async for update in client.get_streaming_response(messages=messages, chat_options=chat_options): updates.append(update) - # Verify we got the expected updates + # Verify we got the expected updates, this shouldn't be dependent on otel assert len(updates) == 2 # Verify telemetry calls were made - mock_get_span.assert_called_once() - mock_set_input.assert_called_once_with("test_provider", messages) - mock_set_output.assert_called_once() + mock_response_span.assert_called_once() + if otel_settings.enable_sensitive_data: + mock_log_messages.assert_called() + assert mock_log_messages.call_count == 2 # One for input, one for output + else: + mock_log_messages.assert_not_called() - -@pytest.mark.parametrize("model_diagnostic_settings", [(True, False)], indirect=True) -async def test_streaming_response_with_exception_via_decorator(mock_chat_client, model_diagnostic_settings): - """Test streaming telemetry exception handling through decorator.""" - - async def _inner_get_streaming_response( - self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any - ) -> AsyncIterable[ChatResponseUpdate]: - yield ChatResponseUpdate(text="Partial", role=Role.ASSISTANT) - raise ValueError("Test streaming error") - - type(mock_chat_client)._inner_get_streaming_response = _inner_get_streaming_response - - decorated_class = use_telemetry(type(mock_chat_client)) - client = decorated_class() - - messages = [ChatMessage(role=Role.USER, text="Test")] - chat_options = ChatOptions() - - with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry.use_span") as mock_use_span, - patch("agent_framework.telemetry._get_chat_response_span"), - patch("agent_framework.telemetry._set_chat_response_input"), - patch("agent_framework.telemetry._set_error") as mock_set_error, - ): - mock_span = Mock() - mock_use_span.return_value.__enter__.return_value = mock_span - mock_use_span.return_value.__exit__.return_value = None - - # Should raise the exception and call error handler - with pytest.raises(ValueError, match="Test streaming error"): - async for _ in client._inner_get_streaming_response(messages=messages, chat_options=chat_options): - pass - - # Verify error was recorded - mock_set_error.assert_called_once() - assert isinstance(mock_set_error.call_args[0][1], ValueError) - - -@pytest.mark.parametrize("model_diagnostic_settings", [(False, False)], indirect=True) -async def test_streaming_response_diagnostics_disabled_via_decorator(model_diagnostic_settings): - """Test streaming response when diagnostics are disabled.""" - from agent_framework import ChatResponseUpdate - - class MockStreamingClientNoDiagnostics: - MODEL_PROVIDER_NAME = "test_provider" - - async def _inner_get_streaming_response( - self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any - ) -> AsyncIterable[ChatResponseUpdate]: - yield ChatResponseUpdate(text="Test", role=Role.ASSISTANT) - - decorated_class = use_telemetry(MockStreamingClientNoDiagnostics) - client = decorated_class() - - messages = [ChatMessage(role=Role.USER, text="Test")] - chat_options = ChatOptions() - - with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry._get_chat_response_span") as mock_get_span, - ): - # Should not create spans when diagnostics are disabled - updates = [] - async for update in client._inner_get_streaming_response(messages=messages, chat_options=chat_options): - updates.append(update) - - assert len(updates) == 1 - # Should not have called telemetry functions - mock_get_span.assert_not_called() - - -# region Test empty streaming response handling - - -@pytest.mark.parametrize("model_diagnostic_settings", [(True, False)], indirect=True) -async def test_empty_streaming_response_via_decorator(model_diagnostic_settings): - """Test streaming wrapper with empty response.""" - - class MockEmptyStreamingClient: - MODEL_PROVIDER_NAME = "test_provider" - - def __init__(self): - self.ai_model_id = "test_model" - - def service_url(self) -> str: - return "https://test.com" - - async def _inner_get_streaming_response( - self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any - ) -> AsyncIterable[ChatResponseUpdate]: - # Return empty stream - return - yield # This will never be reached - - decorated_class = use_telemetry(MockEmptyStreamingClient) - client = decorated_class() - - messages = [ChatMessage(role=Role.USER, text="Test")] - chat_options = ChatOptions() - - with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry.use_span") as mock_use_span, - patch("agent_framework.telemetry._get_chat_response_span"), - patch("agent_framework.telemetry._set_chat_response_input"), - patch("agent_framework.telemetry._set_chat_response_output") as mock_set_output, - ): - mock_span = Mock() - mock_use_span.return_value.__enter__.return_value = mock_span - mock_use_span.return_value.__exit__.return_value = None - - # Should handle empty stream gracefully - updates = [] - async for update in client._inner_get_streaming_response(messages=messages, chat_options=chat_options): - updates.append(update) - - assert len(updates) == 0 - # Should still call telemetry mock_set_output.assert_called_once() def test_start_as_current_span_with_none_metadata(): - """Test start_as_current_span with None metadata.""" + """Test get_function_span with None metadata.""" mock_tracer = Mock() - mock_span = Mock() - mock_tracer.start_as_current_span.return_value = mock_span + with patch("agent_framework.telemetry.tracer", mock_tracer): + mock_span = Mock() + mock_tracer.start_as_current_span.return_value = mock_span - mock_function = Mock() - mock_function.name = "test_function" - mock_function.description = "Test description" + mock_function = Mock() + mock_function.name = "test_function" + mock_function.description = "Test description" - result = start_as_current_span(mock_tracer, mock_function, None) + result = get_function_span(mock_function, None) - assert result == mock_span - call_args = mock_tracer.start_as_current_span.call_args - attributes = call_args[1]["attributes"] - assert GenAIAttributes.TOOL_CALL_ID.value not in attributes + assert result == mock_span + call_args = mock_tracer.start_as_current_span.call_args + attributes = call_args[1]["attributes"] + assert attributes[OtelAttr.TOOL_CALL_ID] == "unknown" def test_prepend_user_agent_with_none_value(): @@ -618,7 +411,6 @@ def test_prepend_user_agent_with_none_value(): def test_agent_decorator_with_valid_class(): """Test that agent decorator works with a valid ChatAgent-like class.""" - from agent_framework.telemetry import use_agent_telemetry # Create a mock class with the required methods class MockChatClientAgent: @@ -639,33 +431,31 @@ def test_agent_decorator_with_valid_class(): return gen() + def get_new_thread(self) -> AgentThread: + return AgentThread() + # Apply the decorator decorated_class = use_agent_telemetry(MockChatClientAgent) - # Check that the methods were wrapped - assert hasattr(decorated_class.run, "__model_diagnostics_agent_run__") - assert hasattr(decorated_class.run_stream, "__model_diagnostics_streaming_agent_run__") + assert hasattr(decorated_class, OPEN_TELEMETRY_AGENT_MARKER) def test_agent_decorator_with_missing_methods(): """Test that agent decorator handles classes missing required methods gracefully.""" - from agent_framework.telemetry import use_agent_telemetry - class MockChatClientAgent: + class MockAgent: AGENT_SYSTEM_NAME = "test_agent_system" # Apply the decorator - should not raise an error - decorated_class = use_agent_telemetry(MockChatClientAgent) - - # Class should be returned unchanged - assert decorated_class is MockChatClientAgent + with pytest.raises(AgentInitializationError): + use_agent_telemetry(MockAgent) def test_agent_decorator_with_partial_methods(): """Test agent decorator when only one method is present.""" from agent_framework.telemetry import use_agent_telemetry - class MockChatClientAgent: + class MockAgent: AGENT_SYSTEM_NAME = "test_agent_system" def __init__(self): @@ -676,11 +466,8 @@ def test_agent_decorator_with_partial_methods(): async def run(self, messages=None, *, thread=None, **kwargs): return Mock() - decorated_class = use_agent_telemetry(MockChatClientAgent) - - # Only the present method should be wrapped - assert hasattr(decorated_class.run, "__model_diagnostics_agent_run__") - assert not hasattr(decorated_class, "run_stream") + with pytest.raises(AgentInitializationError): + use_agent_telemetry(MockAgent) # region Test agent telemetry decorator with mock agent @@ -689,7 +476,6 @@ def test_agent_decorator_with_partial_methods(): @pytest.fixture def mock_chat_client_agent(): """Create a mock chat client agent for testing.""" - from agent_framework import AgentRunResponse, ChatMessage, Role, UsageDetails class MockChatClientAgent: AGENT_SYSTEM_NAME = "test_agent_system" @@ -714,37 +500,16 @@ def mock_chat_client_agent(): yield AgentRunResponseUpdate(text="Hello", role=Role.ASSISTANT) yield AgentRunResponseUpdate(text=" from agent", role=Role.ASSISTANT) - return MockChatClientAgent() + return MockChatClientAgent -@pytest.mark.parametrize("model_diagnostic_settings", [(False, False)], indirect=True) -async def test_agent_telemetry_disabled_bypasses_instrumentation(mock_chat_client_agent, model_diagnostic_settings): - """Test that when agent diagnostics are disabled, telemetry is bypassed.""" - from agent_framework.telemetry import use_agent_telemetry - - decorated_class = use_agent_telemetry(type(mock_chat_client_agent)) - agent = decorated_class() - - with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry.use_span") as mock_use_span, - ): - # This should not create any spans - response = await agent.run("Test message") - assert response is not None - mock_use_span.assert_not_called() - - -@pytest.mark.parametrize("model_diagnostic_settings", [(True, True)], indirect=True) -async def test_agent_instrumentation_enabled(mock_chat_client_agent, model_diagnostic_settings): +@pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True) +async def test_agent_instrumentation_enabled(mock_chat_client_agent: AgentProtocol, otel_settings): """Test that when agent diagnostics are enabled, telemetry is applied.""" - from agent_framework.telemetry import use_agent_telemetry - decorated_class = use_agent_telemetry(type(mock_chat_client_agent)) - agent = decorated_class() + agent = use_agent_telemetry(mock_chat_client_agent)() with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), patch("agent_framework.telemetry.use_span") as mock_use_span, patch("agent_framework.telemetry.logger") as mock_logger, ): @@ -752,30 +517,21 @@ async def test_agent_instrumentation_enabled(mock_chat_client_agent, model_diagn assert response is not None mock_use_span.assert_called_once() # Check that logger.info was called (telemetry logs input/output) - assert mock_logger.info.call_count == 2 + assert mock_logger.info.call_count == (2 if otel_settings.enable_sensitive_data else 0) -@pytest.mark.parametrize("model_diagnostic_settings", [(True, False)], indirect=True) +@pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True) async def test_agent_streaming_response_with_diagnostics_enabled_via_decorator( - mock_chat_client_agent, model_diagnostic_settings + mock_chat_client_agent: AgentProtocol, otel_settings ): """Test agent streaming telemetry through the use_agent_telemetry decorator.""" - from agent_framework.telemetry import use_agent_telemetry - - decorated_class = use_agent_telemetry(type(mock_chat_client_agent)) - agent = decorated_class() + agent = use_agent_telemetry(mock_chat_client_agent)() with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry.use_span") as mock_use_span, - patch("agent_framework.telemetry._get_agent_run_span") as mock_get_span, - patch("agent_framework.telemetry._set_agent_run_input") as mock_set_input, - patch("agent_framework.telemetry._set_agent_run_output") as mock_set_output, + patch("agent_framework.telemetry._get_span") as mock_get_span, + patch("agent_framework.telemetry._capture_messages") as mock_capture_messages, + patch("agent_framework.telemetry._capture_response") as mock_capture_response, ): - mock_span = Mock() - mock_use_span.return_value.__enter__.return_value = mock_span - mock_use_span.return_value.__exit__.return_value = None - # Collect all yielded updates updates = [] async for update in agent.run_stream("Test message"): @@ -786,219 +542,39 @@ async def test_agent_streaming_response_with_diagnostics_enabled_via_decorator( # Verify telemetry calls were made mock_get_span.assert_called_once() - mock_set_input.assert_called_once_with("test_agent_system", "Test message") - mock_set_output.assert_called_once() + mock_capture_response.assert_called_once() + if otel_settings.enable_sensitive_data: + mock_capture_messages.assert_called() + else: + mock_capture_messages.assert_not_called() -@pytest.mark.parametrize("model_diagnostic_settings", [(True, False)], indirect=True) -async def test_agent_streaming_response_with_exception_via_decorator(mock_chat_client_agent, model_diagnostic_settings): - """Test agent streaming telemetry exception handling through decorator.""" - from agent_framework.telemetry import use_agent_telemetry - - async def run_stream(self, messages=None, *, thread=None, **kwargs): - from agent_framework import AgentRunResponseUpdate, Role - - yield AgentRunResponseUpdate(text="Partial", role=Role.ASSISTANT) - raise ValueError("Test agent streaming error") - - type(mock_chat_client_agent).run_stream = run_stream - - decorated_class = use_agent_telemetry(type(mock_chat_client_agent)) - agent = decorated_class() - - with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry.use_span") as mock_use_span, - patch("agent_framework.telemetry._get_agent_run_span"), - patch("agent_framework.telemetry._set_agent_run_input"), - patch("agent_framework.telemetry._set_error") as mock_set_error, - ): - mock_span = Mock() - mock_use_span.return_value.__enter__.return_value = mock_span - mock_use_span.return_value.__exit__.return_value = None - - # Should raise the exception and call error handler - with pytest.raises(ValueError, match="Test agent streaming error"): - async for _ in agent.run_stream("Test message"): - pass - - # Verify error was recorded - mock_set_error.assert_called_once() - assert isinstance(mock_set_error.call_args[0][1], ValueError) - - -@pytest.mark.parametrize("model_diagnostic_settings", [(False, False)], indirect=True) -async def test_agent_streaming_response_diagnostics_disabled_via_decorator(model_diagnostic_settings): - """Test agent streaming response when diagnostics are disabled.""" - from agent_framework import AgentRunResponseUpdate, Role - from agent_framework.telemetry import use_agent_telemetry - - class MockStreamingAgentNoDiagnostics: - AGENT_SYSTEM_NAME = "test_agent_system" - - def __init__(self): - self.id = "test_agent_id" - self.name = "test_agent" - self.display_name = "Test Agent" - - async def run_stream(self, messages=None, *, thread=None, **kwargs): - yield AgentRunResponseUpdate(text="Test", role=Role.ASSISTANT) - - decorated_class = use_agent_telemetry(MockStreamingAgentNoDiagnostics) - agent = decorated_class() - - with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry._get_agent_run_span") as mock_get_span, - ): - # Should not create spans when diagnostics are disabled - updates = [] - async for update in agent.run_stream("Test message"): - updates.append(update) - - assert len(updates) == 1 - # Should not have called telemetry functions - mock_get_span.assert_not_called() - - -@pytest.mark.parametrize("model_diagnostic_settings", [(True, False)], indirect=True) -async def test_agent_empty_streaming_response_via_decorator(model_diagnostic_settings): - """Test agent streaming wrapper with empty response.""" - from agent_framework.telemetry import use_agent_telemetry - - class MockEmptyStreamingAgent: - AGENT_SYSTEM_NAME = "test_agent_system" - - def __init__(self): - self.id = "test_agent_id" - self.name = "test_agent" - self.display_name = "Test Agent" - - async def run_stream(self, messages=None, *, thread=None, **kwargs): - # Return empty stream - return - yield # This will never be reached - - decorated_class = use_agent_telemetry(MockEmptyStreamingAgent) - agent = decorated_class() - - with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry.use_span") as mock_use_span, - patch("agent_framework.telemetry._get_agent_run_span"), - patch("agent_framework.telemetry._set_agent_run_input"), - patch("agent_framework.telemetry._set_agent_run_output") as mock_set_output, - ): - mock_span = Mock() - mock_use_span.return_value.__enter__.return_value = mock_span - mock_use_span.return_value.__exit__.return_value = None - - # Should handle empty stream gracefully - updates = [] - async for update in agent.run_stream("Test message"): - updates.append(update) - - assert len(updates) == 0 - # Should still call telemetry - mock_set_output.assert_called_once() - - -@pytest.mark.parametrize("model_diagnostic_settings", [(True, True)], indirect=True) -async def test_agent_run_with_thread_and_kwargs(mock_chat_client_agent, model_diagnostic_settings): - """Test agent run with thread and additional kwargs.""" - from agent_framework.telemetry import use_agent_telemetry - - decorated_class = use_agent_telemetry(type(mock_chat_client_agent)) - agent = decorated_class() - - # Mock thread - mock_thread = Mock() - mock_thread.id = "test_thread_id" - - with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry.use_span") as mock_use_span, - patch("agent_framework.telemetry._get_agent_run_span") as mock_get_span, - ): - mock_span = Mock() - mock_use_span.return_value.__enter__.return_value = mock_span - mock_use_span.return_value.__exit__.return_value = None - - # Test with thread and additional kwargs - response = await agent.run( - "Test message", thread=mock_thread, temperature=0.7, max_tokens=100, model="test-model" - ) - assert response is not None - - # Verify the span was created with the correct parameters - mock_get_span.assert_called_once() - call_kwargs = mock_get_span.call_args[1] - assert call_kwargs["agent"] == agent - assert call_kwargs["thread"] == mock_thread - assert call_kwargs["temperature"] == 0.7 - assert call_kwargs["max_tokens"] == 100 - assert call_kwargs["model"] == "test-model" - - -@pytest.mark.parametrize("model_diagnostic_settings", [(True, False)], indirect=True) -async def test_agent_run_with_list_messages(mock_chat_client_agent, model_diagnostic_settings): - """Test agent run with list of messages.""" - from agent_framework import ChatMessage, Role - from agent_framework.telemetry import use_agent_telemetry - - decorated_class = use_agent_telemetry(type(mock_chat_client_agent)) - agent = decorated_class() - - messages = [ - ChatMessage(role=Role.USER, text="First message"), - ChatMessage(role=Role.ASSISTANT, text="Response"), - ChatMessage(role=Role.USER, text="Second message"), - ] - - with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry.use_span") as mock_use_span, - patch("agent_framework.telemetry._set_agent_run_input") as mock_set_input, - ): - mock_span = Mock() - mock_use_span.return_value.__enter__.return_value = mock_span - mock_use_span.return_value.__exit__.return_value = None - - response = await agent.run(messages) - assert response is not None - - # Verify input was set with the list of messages - mock_set_input.assert_called_once_with("test_agent_system", messages) - - -@pytest.mark.parametrize("model_diagnostic_settings", [(True, False)], indirect=True) -async def test_agent_run_with_exception_handling(mock_chat_client_agent, model_diagnostic_settings): +async def test_agent_run_with_exception_handling(mock_chat_client_agent: AgentProtocol): """Test agent run with exception handling.""" - from agent_framework.telemetry import use_agent_telemetry async def run_with_error(self, messages=None, *, thread=None, **kwargs): raise RuntimeError("Agent run error") - type(mock_chat_client_agent).run = run_with_error + mock_chat_client_agent.run = run_with_error - decorated_class = use_agent_telemetry(type(mock_chat_client_agent)) - agent = decorated_class() + agent = use_agent_telemetry(mock_chat_client_agent)() + + from opentelemetry.trace import Span with ( - patch("agent_framework.telemetry.MODEL_DIAGNOSTICS_SETTINGS", model_diagnostic_settings), - patch("agent_framework.telemetry.use_span") as mock_use_span, + patch("agent_framework.telemetry._get_span") as mock_get_span, ): - mock_span = Mock() - mock_use_span.return_value.__enter__.return_value = mock_span - mock_use_span.return_value.__exit__.return_value = None - + mock_span = MagicMock(spec=Span) + # Ensure the patched context manager returns mock_span when entered + mock_get_span.return_value.__enter__.return_value = mock_span # Should raise the exception and call error handler with pytest.raises(RuntimeError, match="Agent run error"): await agent.run("Test message") # Verify error was recorded # Check that both error attributes were set on the span - mock_span.set_attribute.assert_called_once_with( - GenAIAttributes.ERROR_TYPE.value, str(type(RuntimeError("Agent run error"))) + mock_span.set_attribute.assert_called_with(OtelAttr.ERROR_TYPE, "RuntimeError") + mock_span.record_exception.assert_called_once() + mock_span.set_status.assert_called_once_with( + status=StatusCode.ERROR, description=repr(RuntimeError("Agent run error")) ) - mock_span.set_status.assert_called_once_with(StatusCode.ERROR, repr(RuntimeError("Agent run error"))) diff --git a/python/packages/main/tests/main/test_tools.py b/python/packages/main/tests/main/test_tools.py index 537b8cc94e..11daff6716 100644 --- a/python/packages/main/tests/main/test_tools.py +++ b/python/packages/main/tests/main/test_tools.py @@ -15,7 +15,7 @@ from agent_framework import ( ) from agent_framework._tools import _parse_inputs from agent_framework.exceptions import ToolException -from agent_framework.telemetry import GenAIAttributes +from agent_framework.telemetry import OtelAttr # region AIFunction and ai_function decorator tests @@ -83,19 +83,23 @@ async def test_ai_function_decorator_with_async(): assert (await async_test_tool(1, 2)) == 3 -# Telemetry tests for AIFunction -async def test_ai_function_invoke_telemetry_enabled(): +@pytest.mark.parametrize("otel_settings", [(True, True)], indirect=True) +async def test_ai_function_invoke_telemetry_enabled(otel_settings): """Test the ai_function invoke method with telemetry enabled.""" - @ai_function(name="telemetry_test_tool", description="A test tool for telemetry") + @ai_function( + name="telemetry_test_tool", + description="A test tool for telemetry", + additional_properties={"otel_settings": otel_settings}, + ) def telemetry_test_tool(x: int, y: int) -> int: """A function that adds two numbers for telemetry testing.""" return x + y # Mock the tracer and span with ( - patch("agent_framework._tools.tracer") as mock_tracer, - patch("agent_framework._tools.start_as_current_span") as mock_start_span, + patch("agent_framework.telemetry.tracer"), + patch("agent_framework._tools.get_function_span") as mock_start_span, ): mock_span = Mock() mock_context_manager = Mock() @@ -114,23 +118,26 @@ async def test_ai_function_invoke_telemetry_enabled(): assert result == 3 # Verify telemetry calls - mock_start_span.assert_called_once_with( - mock_tracer, telemetry_test_tool, metadata={"tool_call_id": "test_call_id", "kwargs": {"x": 1, "y": 2}} - ) + mock_start_span.assert_called_once_with(function=telemetry_test_tool, tool_call_id="test_call_id") # Verify histogram was called with correct attributes mock_histogram.record.assert_called_once() call_args = mock_histogram.record.call_args assert call_args[0][0] > 0 # duration should be positive attributes = call_args[1]["attributes"] - assert attributes[GenAIAttributes.MEASUREMENT_FUNCTION_TAG_NAME.value] == "telemetry_test_tool" - assert attributes[GenAIAttributes.TOOL_CALL_ID.value] == "test_call_id" + assert attributes[OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME] == "telemetry_test_tool" + assert attributes[OtelAttr.TOOL_CALL_ID] == "test_call_id" -async def test_ai_function_invoke_telemetry_with_pydantic_args(): +@pytest.mark.parametrize("otel_settings", [(True, True)], indirect=True) +async def test_ai_function_invoke_telemetry_with_pydantic_args(otel_settings): """Test the ai_function invoke method with Pydantic model arguments.""" - @ai_function(name="pydantic_test_tool", description="A test tool with Pydantic args") + @ai_function( + name="pydantic_test_tool", + description="A test tool with Pydantic args", + additional_properties={"otel_settings": otel_settings}, + ) def pydantic_test_tool(x: int, y: int) -> int: """A function that adds two numbers using Pydantic args.""" return x + y @@ -139,8 +146,8 @@ async def test_ai_function_invoke_telemetry_with_pydantic_args(): args_model = pydantic_test_tool.input_model(x=5, y=10) with ( - patch("agent_framework._tools.tracer") as mock_tracer, - patch("agent_framework._tools.start_as_current_span") as mock_start_span, + patch("agent_framework.telemetry.tracer"), + patch("agent_framework._tools.get_function_span") as mock_start_span, ): mock_span = Mock() mock_context_manager = Mock() @@ -159,21 +166,27 @@ async def test_ai_function_invoke_telemetry_with_pydantic_args(): # Verify telemetry calls mock_start_span.assert_called_once_with( - mock_tracer, pydantic_test_tool, metadata={"tool_call_id": "pydantic_call", "kwargs": {"x": 5, "y": 10}} + function=pydantic_test_tool, + tool_call_id="pydantic_call", ) -async def test_ai_function_invoke_telemetry_with_exception(): +@pytest.mark.parametrize("otel_settings", [(True, True)], indirect=True) +async def test_ai_function_invoke_telemetry_with_exception(otel_settings): """Test the ai_function invoke method with telemetry when an exception occurs.""" - @ai_function(name="exception_test_tool", description="A test tool that raises an exception") + @ai_function( + name="exception_test_tool", + description="A test tool that raises an exception", + additional_properties={"otel_settings": otel_settings}, + ) def exception_test_tool(x: int, y: int) -> int: """A function that raises an exception for telemetry testing.""" raise ValueError("Test exception for telemetry") with ( - patch("agent_framework._tools.tracer"), - patch("agent_framework._tools.start_as_current_span") as mock_start_span, + patch("agent_framework.telemetry.tracer"), + patch("agent_framework._tools.get_function_span") as mock_start_span, ): mock_span = Mock() mock_context_manager = Mock() @@ -200,20 +213,25 @@ async def test_ai_function_invoke_telemetry_with_exception(): mock_histogram.record.assert_called_once() call_args = mock_histogram.record.call_args attributes = call_args[1]["attributes"] - assert attributes[GenAIAttributes.ERROR_TYPE.value] == "ValueError" + assert attributes[OtelAttr.ERROR_TYPE] == ValueError.__name__ -async def test_ai_function_invoke_telemetry_async_function(): +@pytest.mark.parametrize("otel_settings", [(True, True)], indirect=True) +async def test_ai_function_invoke_telemetry_async_function(otel_settings): """Test the ai_function invoke method with telemetry on async function.""" - @ai_function(name="async_telemetry_test", description="An async test tool for telemetry") + @ai_function( + name="async_telemetry_test", + description="An async test tool for telemetry", + additional_properties={"otel_settings": otel_settings}, + ) async def async_telemetry_test(x: int, y: int) -> int: """An async function for telemetry testing.""" return x * y with ( - patch("agent_framework._tools.tracer") as mock_tracer, - patch("agent_framework._tools.start_as_current_span") as mock_start_span, + patch("agent_framework.telemetry.tracer"), + patch("agent_framework._tools.get_function_span") as mock_start_span, ): mock_span = Mock() mock_context_manager = Mock() @@ -231,54 +249,13 @@ async def test_ai_function_invoke_telemetry_async_function(): assert result == 12 # Verify telemetry calls - mock_start_span.assert_called_once_with( - mock_tracer, async_telemetry_test, metadata={"tool_call_id": "async_call", "kwargs": {"x": 3, "y": 4}} - ) + mock_start_span.assert_called_once_with(function=async_telemetry_test, tool_call_id="async_call") # Verify histogram recording mock_histogram.record.assert_called_once() call_args = mock_histogram.record.call_args attributes = call_args[1]["attributes"] - assert attributes[GenAIAttributes.MEASUREMENT_FUNCTION_TAG_NAME.value] == "async_telemetry_test" - - -async def test_ai_function_invoke_telemetry_no_tool_call_id(): - """Test the ai_function invoke method with telemetry when no tool_call_id is provided.""" - - @ai_function(name="no_id_test_tool", description="A test tool without tool_call_id") - def no_id_test_tool(x: int) -> int: - """A function for testing without tool_call_id.""" - return x * 2 - - with ( - patch("agent_framework._tools.tracer") as mock_tracer, - patch("agent_framework._tools.start_as_current_span") as mock_start_span, - ): - mock_span = Mock() - mock_context_manager = Mock() - mock_context_manager.__enter__ = Mock(return_value=mock_span) - mock_context_manager.__exit__ = Mock(return_value=None) - mock_start_span.return_value = mock_context_manager - - mock_histogram = Mock() - no_id_test_tool._invocation_duration_histogram = mock_histogram - - # Call invoke without tool_call_id - result = await no_id_test_tool.invoke(x=5) - - # Verify result - assert result == 10 - - # Verify telemetry calls - mock_start_span.assert_called_once_with( - mock_tracer, no_id_test_tool, metadata={"tool_call_id": None, "kwargs": {"x": 5}} - ) - - # Verify histogram attributes - mock_histogram.record.assert_called_once() - call_args = mock_histogram.record.call_args - attributes = call_args[1]["attributes"] - assert attributes[GenAIAttributes.TOOL_CALL_ID.value] is None + assert attributes[OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME] == "async_telemetry_test" async def test_ai_function_invoke_invalid_pydantic_args(): diff --git a/python/packages/main/tests/openai/conftest.py b/python/packages/main/tests/openai/conftest.py index beb20ead82..7bedccd52d 100644 --- a/python/packages/main/tests/openai/conftest.py +++ b/python/packages/main/tests/openai/conftest.py @@ -3,6 +3,8 @@ from typing import Any from pytest import fixture +from agent_framework.telemetry import OtelSettings, setup_telemetry + # region Connector Settings fixtures @fixture @@ -49,3 +51,26 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # monkeypatch.setenv(key, value) # type: ignore return env_vars + + +@fixture +def enable_otel(request: Any) -> bool: + """Fixture that returns a boolean indicating if Otel is enabled.""" + return request.param if hasattr(request, "param") else True + + +@fixture +def enable_sensitive_data(request: Any) -> bool: + """Fixture that returns a boolean indicating if sensitive data is enabled.""" + return request.param if hasattr(request, "param") else False + + +@fixture +def otel_settings(enable_otel: bool, enable_sensitive_data: bool) -> OtelSettings: + """Fixture to set environment variables for OtelSettings.""" + + from agent_framework.telemetry import OTEL_SETTINGS + + setup_telemetry(enable_otel=enable_otel, enable_sensitive_data=enable_sensitive_data) + + return OTEL_SETTINGS diff --git a/python/packages/main/tests/openai/test_openai_responses_client.py b/python/packages/main/tests/openai/test_openai_responses_client.py index 6b89329f4e..4f2846a981 100644 --- a/python/packages/main/tests/openai/test_openai_responses_client.py +++ b/python/packages/main/tests/openai/test_openai_responses_client.py @@ -373,7 +373,7 @@ def test_chat_message_parsing_with_function_calls() -> None: asyncio.run(client.get_response(messages=messages)) -def test_response_format_parse_path() -> None: +async def test_response_format_parse_path() -> None: """Test get_response response_format parsing path.""" client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key") @@ -386,19 +386,18 @@ def test_response_format_parse_path() -> None: mock_parsed_response.metadata = {} mock_parsed_response.output_parsed = None mock_parsed_response.usage = None + mock_parsed_response.finish_reason = None with patch.object(client.client.responses, "parse", return_value=mock_parsed_response): - response = asyncio.run( - client.get_response( - messages=[ChatMessage(role="user", text="Test message")], response_format=OutputStruct, store=True - ) + response = await client.get_response( + messages=[ChatMessage(role="user", text="Test message")], response_format=OutputStruct, store=True ) assert response.conversation_id == "parsed_response_123" assert response.ai_model_id == "test-model" -def test_bad_request_error_non_content_filter() -> None: +async def test_bad_request_error_non_content_filter() -> None: """Test get_response BadRequestError without content_filter.""" client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key") @@ -412,10 +411,8 @@ def test_bad_request_error_non_content_filter() -> None: with patch.object(client.client.responses, "parse", side_effect=mock_error): with pytest.raises(ServiceResponseException) as exc_info: - asyncio.run( - client.get_response( - messages=[ChatMessage(role="user", text="Test message")], response_format=OutputStruct - ) + await client.get_response( + messages=[ChatMessage(role="user", text="Test message")], response_format=OutputStruct ) assert "failed to complete the prompt" in str(exc_info.value) @@ -440,11 +437,13 @@ async def test_streaming_content_filter_exception_handling() -> None: break -def test_get_streaming_response_with_all_parameters() -> None: +@skip_if_openai_integration_tests_disabled +async def test_get_streaming_response_with_all_parameters() -> None: """Test get_streaming_response with all possible parameters.""" client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key") - async def run_streaming_test(): + # Should fail due to invalid API key + with pytest.raises(ServiceResponseException): response = client.get_streaming_response( messages=[ChatMessage(role="user", text="Test streaming")], include=["file_search_call.results"], @@ -471,10 +470,6 @@ def test_get_streaming_response_with_all_parameters() -> None: async for _ in response: break - # Should fail due to invalid API key - with pytest.raises(ServiceResponseException): - asyncio.run(run_streaming_test()) - def test_response_content_creation_with_annotations() -> None: """Test _create_response_content with different annotation types.""" @@ -731,7 +726,9 @@ def test_create_streaming_response_content_with_mcp_approval_request() -> None: assert fa.function_call.name == "do_stream_action" -def test_end_to_end_mcp_approval_flow() -> None: +@pytest.mark.parametrize("enable_otel", [False], indirect=True) +@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True) +def test_end_to_end_mcp_approval_flow(otel_settings) -> None: """End-to-end mocked test: model issues an mcp_approval_request, user approves, client sends mcp_approval_response. """ diff --git a/python/packages/workflow/agent_framework_workflow/_telemetry.py b/python/packages/workflow/agent_framework_workflow/_telemetry.py index 625e81aa67..682a90b040 100644 --- a/python/packages/workflow/agent_framework_workflow/_telemetry.py +++ b/python/packages/workflow/agent_framework_workflow/_telemetry.py @@ -35,11 +35,11 @@ class WorkflowDiagnosticSettings(AFBaseSettings): """Settings for workflow tracing diagnostics.""" env_prefix: ClassVar[str] = "AGENT_FRAMEWORK_WORKFLOW_" - enable_otel_diagnostics: bool = False + enable_otel: bool = False @property def ENABLED(self) -> bool: - return self.enable_otel_diagnostics + return self.enable_otel class WorkflowTracer: diff --git a/python/packages/workflow/tests/test_tracing.py b/python/packages/workflow/tests/test_tracing.py index 021012f6fd..d42132402a 100644 --- a/python/packages/workflow/tests/test_tracing.py +++ b/python/packages/workflow/tests/test_tracing.py @@ -22,8 +22,8 @@ from agent_framework_workflow._workflow_context import WorkflowContext @pytest.fixture def tracing_enabled() -> Generator[None, None, None]: """Enable tracing for tests.""" - original_value = os.environ.get("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS") - os.environ["AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS"] = "true" + original_value = os.environ.get("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL") + os.environ["AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL"] = "true" # Force reload the settings to pick up the environment variable from agent_framework_workflow._telemetry import WorkflowDiagnosticSettings @@ -34,9 +34,9 @@ def tracing_enabled() -> Generator[None, None, None]: # Restore original value if original_value is None: - os.environ.pop("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS", None) + os.environ.pop("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL", None) else: - os.environ["AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS"] = original_value + os.environ["AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL"] = original_value # Reload settings again workflow_tracer.settings = WorkflowDiagnosticSettings() @@ -142,7 +142,6 @@ class FanInAggregator(Executor): return self._processed_messages -@pytest.mark.asyncio async def test_workflow_tracer_configuration() -> None: """Test that workflow tracer can be enabled and disabled.""" # Test disabled by default @@ -150,8 +149,8 @@ async def test_workflow_tracer_configuration() -> None: assert not tracer.enabled # Test enabled with environment variable - original_value = os.environ.get("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS") - os.environ["AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS"] = "true" + original_value = os.environ.get("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL") + os.environ["AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL"] = "true" # Force reload the settings to pick up the environment variable from agent_framework_workflow._telemetry import WorkflowDiagnosticSettings @@ -162,15 +161,14 @@ async def test_workflow_tracer_configuration() -> None: # Restore original value if original_value is None: - os.environ.pop("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS", None) + os.environ.pop("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL", None) else: - os.environ["AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS"] = original_value + os.environ["AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL"] = original_value # Reload settings again tracer.settings = WorkflowDiagnosticSettings() -@pytest.mark.asyncio async def test_span_creation_and_attributes(tracing_enabled: Any, span_exporter: InMemorySpanExporter) -> None: """Test creation and attributes of all span types (workflow, processing, sending).""" # Create a mock workflow object @@ -228,7 +226,6 @@ async def test_span_creation_and_attributes(tracing_enabled: Any, span_exporter: assert sending_span.attributes.get("message.destination_executor_id") == "target-789" -@pytest.mark.asyncio async def test_trace_context_handling(tracing_enabled: Any, span_exporter: InMemorySpanExporter) -> None: """Test trace context propagation and handling in messages and executors.""" shared_state = SharedState() diff --git a/python/samples/getting_started/agents/openai_responses_client/openai_responses_client_with_local_mcp.py b/python/samples/getting_started/agents/openai_responses_client/openai_responses_client_with_local_mcp.py index 11302318de..a3935b87a5 100644 --- a/python/samples/getting_started/agents/openai_responses_client/openai_responses_client_with_local_mcp.py +++ b/python/samples/getting_started/agents/openai_responses_client/openai_responses_client_with_local_mcp.py @@ -13,7 +13,6 @@ async def streaming_with_mcp(show_raw_stream: bool = False) -> None: through the raw_representation. You can view this, by setting the show_raw_stream parameter to True. """ print("=== Tools Defined on Agent Level ===") - # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime async with ChatAgent( diff --git a/python/samples/getting_started/telemetry/.env.example b/python/samples/getting_started/telemetry/.env.example index 5e8bced445..3c1632a83a 100644 --- a/python/samples/getting_started/telemetry/.env.example +++ b/python/samples/getting_started/telemetry/.env.example @@ -1,5 +1,13 @@ -CONNECTION_STRING="..." +# Connector environment variables +# Foundry +# see ../../../env.example for details +# OpenAI +# see ../../../env.example for details + +# Otel specific variables +APPLICATION_INSIGHTS_CONNECTION_STRING="..." +APPLICATION_INSIGHTS_LIVE_METRICS=true OTLP_ENDPOINT="http://localhost:4317/" -AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS=true -AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE=true -AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS=true +ENABLE_OTEL=true +ENABLE_SENSITIVE_DATA=true +WORKFLOW_ENABLE_OTEL=true diff --git a/python/samples/getting_started/telemetry/01-zero_code.py b/python/samples/getting_started/telemetry/01-zero_code.py new file mode 100644 index 0000000000..6fd6e06e79 --- /dev/null +++ b/python/samples/getting_started/telemetry/01-zero_code.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft. All rights reserved. +# type: ignore +import asyncio +import os +from random import randint +from typing import TYPE_CHECKING, Annotated + +from agent_framework.openai import OpenAIResponsesClient +from pydantic import Field + +if TYPE_CHECKING: + from agent_framework import ChatClientProtocol + + +""" +This is the simplest sample of using the Agent Framework with telemetry. +Since it does not create a tracer or span in the script's code, we can let the Agent Framework SDK handle everything. +If the environment variables are set correctly, +the SDK will automatically initialize telemetry and collect traces and logs. +""" + + +if "AGENT_FRAMEWORK_ENABLE_OTEL" not in os.environ: + print("Set AGENT_FRAMEWORK_ENABLE_OTEL to enable telemetry with a OTLP endpoint.") +if "AGENT_FRAMEWORK_OTLP_ENDPOINT" not in os.environ and "AGENT_FRAMEWORK_MONITOR_CONNECTION_STRING" not in os.environ: + print("Set AGENT_FRAMEWORK_OTLP_ENDPOINT or AGENT_FRAMEWORK_MONITOR_CONNECTION_STRING to enable telemetry.") + + +async def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + await asyncio.sleep(randint(0, 10) / 10.0) # Simulate a network call + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def run_chat_client(client: "ChatClientProtocol", stream: bool = False) -> None: + """Run an AI service. + + This function runs an AI service and prints the output. + Telemetry will be collected for the service execution behind the scenes, + and the traces will be sent to the configured telemetry backend. + + The telemetry will include information about the AI service execution. + + Args: + stream: Whether to use streaming for the plugin + + Remarks: + When function calling is outside the open telemetry loop + each of the call to the model is handled as a seperate span, + while when the open telemetry is put last, a single span + is shown, which might include one or more rounds of function calling. + + So for the scenario below, you should see the following: + + 2 spans with gen_ai.operation.name=chat + The first has finish_reason "tool_calls" + The second has finish_reason "stop" + 2 spans with gen_ai.operation.name=execute_tool + + """ + message = "What's the weather in Amsterdam and in Paris?" + print(f"User: {message}") + if stream: + print("Assistant: ", end="") + async for chunk in client.get_streaming_response(message, tools=get_weather): + if str(chunk): + print(str(chunk), end="") + print("") + else: + response = await client.get_response(message, tools=get_weather) + print(f"Assistant: {response}") + + +async def main() -> None: + client = OpenAIResponsesClient() + + # Scenarios where telemetry is collected in the SDK, from the most basic to the most complex. + await run_chat_client(client, stream=True) + await run_chat_client(client, stream=False) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/telemetry/02a-generic_chat_client.py b/python/samples/getting_started/telemetry/02a-generic_chat_client.py new file mode 100644 index 0000000000..6fb85a440a --- /dev/null +++ b/python/samples/getting_started/telemetry/02a-generic_chat_client.py @@ -0,0 +1,134 @@ +# Copyright (c) Microsoft. All rights reserved. +# type: ignore +import argparse +import asyncio +from contextlib import suppress +from random import randint +from typing import TYPE_CHECKING, Annotated, Literal + +from agent_framework import __version__, ai_function +from agent_framework.openai import OpenAIResponsesClient +from agent_framework.telemetry import setup_telemetry +from opentelemetry import trace +from opentelemetry.trace import SpanKind +from opentelemetry.trace.span import format_trace_id +from pydantic import Field + +if TYPE_CHECKING: + from agent_framework import ChatClientProtocol + +""" +This sample, show how you can get telemetry from a chat client and tool. +it explicitly calls the `setup_telemetry` function to set up telemetry in order to include the overall spans, +those are defined in the main and run_* functions. +""" + + +# Define the scenarios that can be run +SCENARIOS = ["chat_client", "chat_client_stream", "ai_function", "all"] + + +async def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + await asyncio.sleep(randint(0, 10) / 10.0) # Simulate a network call + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def run_chat_client(client: "ChatClientProtocol", stream: bool = False) -> None: + """Run an AI service. + + This function runs an AI service and prints the output. + Telemetry will be collected for the service execution behind the scenes, + and the traces will be sent to the configured telemetry backend. + + The telemetry will include information about the AI service execution. + + Args: + client: The chat client to use. + stream: Whether to use streaming for the response + + Remarks: + For the scenario below, you should see the following: + 1 Client span, with 4 children: + 2 Internal span with gen_ai.operation.name=chat + The first has finish_reason "tool_calls" + The second has finish_reason "stop" + 2 Internal span with gen_ai.operation.name=execute_tool + + """ + scenario_name = "Chat Client Stream" if stream else "Chat Client" + + tracer = trace.get_tracer("agent_framework", __version__) + with tracer.start_as_current_span(name=f"Scenario: {scenario_name}", kind=SpanKind.CLIENT): + print("Running scenario:", scenario_name) + message = "What's the weather in Amsterdam and in Paris?" + print(f"User: {message}") + if stream: + print("Assistant: ", end="") + async for chunk in client.get_streaming_response(message, tools=get_weather): + if str(chunk): + print(str(chunk), end="") + print("") + else: + response = await client.get_response(message, tools=get_weather) + print(f"Assistant: {response}") + + +async def run_ai_function() -> None: + """Run a AI function. + + This function runs a AI function and prints the output. + Telemetry will be collected for the function execution behind the scenes, + and the traces will be sent to the configured telemetry backend. + + The telemetry will include information about the AI function execution + and the AI service execution. + """ + + tracer = trace.get_tracer("agent_framework", __version__) + with tracer.start_as_current_span("Scenario: AI Function", kind=SpanKind.CLIENT): + print("Running scenario: AI Function") + func = ai_function(get_weather) + weather = await func.invoke(location="Amsterdam") + print(f"Weather in Amsterdam:\n{weather}") + + +async def main(scenario: Literal["chat_client", "chat_client_stream", "ai_function", "all"] = "all"): + """Run the selected scenario(s).""" + + setup_telemetry() + + tracer = trace.get_tracer("My application", __version__) + with tracer.start_as_current_span("Sample Scenario's", kind=SpanKind.CLIENT) as current_span: + print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") + + client = OpenAIResponsesClient() + + # Scenarios where telemetry is collected in the SDK, from the most basic to the most complex. + if scenario == "chat_client_stream" or scenario == "all": + with suppress(Exception): + await run_chat_client(client, stream=True) + if scenario == "chat_client" or scenario == "all": + with suppress(Exception): + await run_chat_client(client, stream=False) + if scenario == "ai_function" or scenario == "all": + with suppress(Exception): + await run_ai_function() + + +if __name__ == "__main__": + arg_parser = argparse.ArgumentParser() + + arg_parser.add_argument( + "--scenario", + type=str, + choices=SCENARIOS, + default="all", + help="The scenario to run. Default is all.", + ) + + args = arg_parser.parse_args() + asyncio.run(main(args.scenario)) diff --git a/python/samples/getting_started/telemetry/02b-foundry_chat_client.py b/python/samples/getting_started/telemetry/02b-foundry_chat_client.py new file mode 100644 index 0000000000..fa4706a016 --- /dev/null +++ b/python/samples/getting_started/telemetry/02b-foundry_chat_client.py @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft. All rights reserved. +# type: ignore +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import HostedCodeInterpreterTool +from agent_framework.telemetry import setup_telemetry +from agent_framework_foundry import FoundryChatClient +from azure.ai.projects.aio import AIProjectClient +from azure.identity.aio import AzureCliCredential +from opentelemetry import trace +from opentelemetry.trace import SpanKind +from opentelemetry.trace.span import format_trace_id +from pydantic import Field + +""" +This sample, shows you can leverage the built-in telemetry in Foundry. +It uses the Foundry client to setup the telemetry, this calls +out to Foundry for a telemetry connection strings, +and then call the setup_telemetry function in the agent framework. +If you want to compare with the trace sent to a generic OTLP endpoint, +switch the `use_foundry_telemetry` variable to False. +""" + + +# ANSI color codes for printing in blue and resetting after each print +BLUE = "\x1b[34m" +RESET = "\x1b[0m" + + +async def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + await asyncio.sleep(randint(0, 10) / 10.0) # Simulate a network call + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def main() -> None: + """Run an AI service. + + This function runs an AI service and prints the output. + Telemetry will be collected for the service execution behind the scenes, + and the traces will be sent to the configured telemetry backend. + + The telemetry will include information about the AI service execution. + + In foundry you will also see specific operations happening that are called by the Foundry implementation, + such as `create_agent`. + """ + use_foundry_telemetry = True + questions = [ + "What's the weather in Amsterdam and in Paris?", + "Why is the sky blue?", + "Tell me about AI.", + "Can you write a python function that adds two numbers? and use it to add 8483 and 5692?", + ] + async with ( + AzureCliCredential() as credential, + AIProjectClient(endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=credential) as project, + FoundryChatClient(client=project, setup_tracing=False) as client, + ): + if use_foundry_telemetry: + await client.setup_foundry_telemetry(enable_live_metrics=True) + else: + setup_telemetry() + + tracer = trace.get_tracer("agent_framework") + with tracer.start_as_current_span(name="Foundry Telemetry from Agent Framework", kind=SpanKind.CLIENT) as span: + for question in questions: + print(f"{BLUE}User: {question}{RESET}") + print(f"{BLUE}Assistant: {RESET}", end="") + async for chunk in client.get_streaming_response( + question, tools=[get_weather, HostedCodeInterpreterTool()] + ): + if str(chunk): + print(f"{BLUE}{str(chunk)}{RESET}", end="") + print(f"{BLUE}{RESET}") + + print(f"{BLUE}Done{RESET}") + print(f"{BLUE}Operation ID: {format_trace_id(span.get_span_context().trace_id)}{RESET}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/telemetry/03a-agent.py b/python/samples/getting_started/telemetry/03a-agent.py new file mode 100644 index 0000000000..4bdb111321 --- /dev/null +++ b/python/samples/getting_started/telemetry/03a-agent.py @@ -0,0 +1,59 @@ +# Copyright (c) Microsoft. All rights reserved. +# type: ignore +import asyncio +from random import randint +from typing import Annotated + +from agent_framework import ChatAgent +from agent_framework.openai import OpenAIChatClient +from agent_framework.telemetry import setup_telemetry +from opentelemetry import trace +from opentelemetry.trace import SpanKind +from pydantic import Field + +""" +This sample shows you can can setup telemetry with a agent. +The agent invoke is a additional Semantic Convention that now +will wrap the calls made by the underlying chat client and tools. +""" + + +async def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + await asyncio.sleep(randint(0, 10) / 10.0) # Simulate a network call + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def main(): + # Set up the telemetry + setup_telemetry() + + questions = ["What's the weather in Amsterdam?", "and in Paris, and which is better?", "Why is the sky blue?"] + + tracer = trace.get_tracer("agent_framework") + with tracer.start_as_current_span("Scenario: Agent Chat", kind=SpanKind.CLIENT): + print("Running scenario: Agent Chat") + print("Welcome to the chat, type 'exit' to quit.") + agent = ChatAgent( + chat_client=OpenAIChatClient(), + tools=get_weather, + name="WeatherAgent", + instructions="You are a weather assistant.", + ) + thread = agent.get_new_thread() + for question in questions: + print(f"User: {question}") + print(f"{agent.display_name}: ", end="") + async for update in agent.run_stream( + question, + thread=thread, + ): + if update.text: + print(update.text, end="") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/telemetry/03b-foundry_agent.py b/python/samples/getting_started/telemetry/03b-foundry_agent.py new file mode 100644 index 0000000000..4c5bc414a3 --- /dev/null +++ b/python/samples/getting_started/telemetry/03b-foundry_agent.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft. All rights reserved. +# type: ignore +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import ChatAgent +from agent_framework_foundry import FoundryChatClient +from azure.ai.projects.aio import AIProjectClient +from azure.identity.aio import AzureCliCredential +from opentelemetry import trace +from opentelemetry.trace import SpanKind +from pydantic import Field + +""" +This sample shows you can can setup telemetry with a agent from Foundry. +We once again call the `setup_foundry_telemetry` method to set up telemetry in order to include the overall spans. +""" + + +async def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + await asyncio.sleep(randint(0, 10) / 10.0) # Simulate a network call + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def main(): + # Set up the providers + # This must be done before any other telemetry calls + questions = ["What's the weather in Amsterdam?", "and in Paris, and which is better?", "Why is the sky blue?"] + async with ( + AzureCliCredential() as credential, + AIProjectClient(endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=credential) as project, + # this calls `setup_foundry_telemetry` through the context manager + FoundryChatClient(client=project) as client, + ): + await client.setup_foundry_telemetry(enable_live_metrics=True) + tracer = trace.get_tracer("agent_framework") + with tracer.start_as_current_span("Single Agent Chat", kind=SpanKind.CLIENT): + print("Running Single Agent Chat") + print("Welcome to the chat, type 'exit' to quit.") + agent = ChatAgent( + chat_client=client, + tools=get_weather, + name="WeatherAgent", + instructions="You are a weather assistant.", + ) + thread = agent.get_new_thread() + for question in questions: + print(f"User: {question}") + print(f"{agent.display_name}: ", end="") + async for update in agent.run_stream( + question, + thread=thread, + ): + if update.text: + print(update.text, end="") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/telemetry/04-workflow.py b/python/samples/getting_started/telemetry/04-workflow.py new file mode 100644 index 0000000000..e07df885e2 --- /dev/null +++ b/python/samples/getting_started/telemetry/04-workflow.py @@ -0,0 +1,120 @@ +# Copyright (c) Microsoft. All rights reserved. +# type: ignore +import asyncio +from typing import Any + +from agent_framework.telemetry import setup_telemetry +from agent_framework.workflow import ( + Executor, + WorkflowBuilder, + WorkflowCompletedEvent, + WorkflowContext, + handler, +) +from opentelemetry import trace +from opentelemetry.trace import SpanKind +from opentelemetry.trace.span import format_trace_id + +"""Telemetry sample demonstrating OpenTelemetry integration with Agent Framework workflows. + +This sample runs a simple sequential workflow with telemetry collection, +showing telemetry collection for workflow execution, executor processing, +and message publishing between executors. +""" + + +# Executors for sequential workflow +class UpperCaseExecutor(Executor): + """An executor that converts text to uppercase.""" + + @handler + async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None: + """Execute the task by converting the input string to uppercase.""" + print(f"UpperCaseExecutor: Processing '{text}'") + result = text.upper() + print(f"UpperCaseExecutor: Result '{result}'") + + # Send the result to the next executor in the workflow. + await ctx.send_message(result) + + +class ReverseTextExecutor(Executor): + """An executor that reverses text.""" + + @handler + async def reverse_text(self, text: str, ctx: WorkflowContext[Any]) -> None: + """Execute the task by reversing the input string.""" + print(f"ReverseTextExecutor: Processing '{text}'") + result = text[::-1] + print(f"ReverseTextExecutor: Result '{result}'") + + # Send the result with a workflow completion event. + await ctx.add_event(WorkflowCompletedEvent(result)) + + +async def run_sequential_workflow() -> None: + """Run a simple sequential workflow demonstrating telemetry collection. + + This workflow processes a string through two executors in sequence: + 1. UpperCaseExecutor converts the input to uppercase + 2. ReverseTextExecutor reverses the string and completes the workflow + + Telemetry data collected includes: + - Overall workflow execution spans + - Individual executor processing spans + - Message publishing between executors + - Workflow completion events + """ + + tracer = trace.get_tracer(__name__) + with tracer.start_as_current_span("Scenario: Sequential Workflow", kind=SpanKind.CLIENT) as current_span: + print("Running scenario: Sequential Workflow") + try: + # Step 1: Create the executors. + upper_case_executor = UpperCaseExecutor(id="upper_case_executor") + reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor") + + # Step 2: Build the workflow with the defined edges. + workflow = ( + WorkflowBuilder() + .add_edge(upper_case_executor, reverse_text_executor) + .set_start_executor(upper_case_executor) + .build() + ) + + # Step 3: Run the workflow with an initial message. + input_text = "hello world" + print(f"Starting workflow with input: '{input_text}'") + + completion_event = None + async for event in workflow.run_stream(input_text): + print(f"Event: {event}") + if isinstance(event, WorkflowCompletedEvent): + # The WorkflowCompletedEvent contains the final result. + completion_event = event + + if completion_event: + print(f"Workflow completed with result: '{completion_event.data}'") + else: + print("Workflow completed without a completion event") + + except Exception as e: + current_span.record_exception(e) + print(f"Error running workflow: {e}") + + +async def main(): + """Run the telemetry sample with a simple sequential workflow.""" + + setup_telemetry() + + tracer = trace.get_tracer("agent_framework") + with tracer.start_as_current_span("Sequential Workflow Scenario", kind=SpanKind.CLIENT) as current_span: + print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") + + # Run the sequential workflow scenario + await run_sequential_workflow() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/telemetry/README.md b/python/samples/getting_started/telemetry/README.md index 5d947cda17..b574aa4251 100644 --- a/python/samples/getting_started/telemetry/README.md +++ b/python/samples/getting_started/telemetry/README.md @@ -1,8 +1,8 @@ # Agent Framework Python Telemetry -This sample project shows how a Python application can be configured to send Agent Framework telemetry to the Application Performance Management (APM) vendors of your choice. +This sample folder shows how a Python application can be configured to send Agent Framework telemetry to the Application Performance Management (APM) vendors of your choice. -In this sample, we provide options to send telemetry to [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview), [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash), and console output. +In this sample, we provide options to send telemetry to [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview) and [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash). > **Quick Start**: For local development without Azure setup, you can use the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone) which runs locally via Docker and provides an excellent telemetry viewing experience for OpenTelemetry data. @@ -24,6 +24,7 @@ The Agent Framework Python SDK is designed to efficiently generate comprehensive ### Required resources 2. OpenAI or [Azure OpenAI](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/create-resource?pivots=web-portal) +2. [Foundry project](https://ai.azure.com/doc/azure/ai-foundry/what-is-azure-ai-foundry) ### Optional resources @@ -31,61 +32,51 @@ The Agent Framework Python SDK is designed to efficiently generate comprehensive 2. [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone-for-python?tabs=flask%2Cwindows#start-the-aspire-dashboard) ### Dependencies +No additional dependencies are required to enable telemetry. The necessary packages are included as part of the `agent-framework` package. Unless you want to use a different APM vendor, in which case you will need to install the appropriate OpenTelemetry exporter package. -You will also need to install the following dependencies to your virtual environment to run this sample: +### Environment variables +The following environment variables can be set to configure telemetry, the first two set the basic configuration: -```bash -# For Azure ApplicationInsights/AzureMonitor -uv pip install azure-monitor-opentelemetry azure-monitor-opentelemetry-exporter -# For OTLP endpoint -uv pip install opentelemetry-exporter-otlp-proto-grpc -``` +- AGENT_FRAMEWORK_ENABLE_OTEL=true +- AGENT_FRAMEWORK_ENABLE_SENSITIVE_DATA=true -## Running the sample +Next we need to know where to send the telemetry, for that you can use either a OTLP endpoint or a connection string for Application Insights: +- AGENT_FRAMEWORK_OTLP_ENDPOINT="" +or +- AGENT_FRAMEWORK_MONITOR_CONNECTION_STRING="" +Finally, you can enable live metrics streaming to Application Insights: +- AGENT_FRAMEWORK_MONITOR_LIVE_METRICS=true + +> IMPORTANT - If both OTLP endpoint and connection string are set, the connection string will take precedence and there will be no trace to the OTLP endpoint. + +## Samples +This folder contains different samples demonstrating how to use telemetry in various scenarios. + +### [01 - zero_code](./01-zero_code.py): +A simple example showing how to enable telemetry in a zero-touch scenario. When the above environment variables are set, telemetry will be automatically enabled, however since you do not define any overarching tracer, you will only see the spans for the specific calls to the chat client and tools. + +### [02a](./02a-generic_chat_client.py) and [02b](./02b-foundry_chat_client.py) Chat Clients: +These two samples show how to first setup the telemetry by manually importing the `setup_telemetry` function from the `agent_framework.telemetry` module and calling it. After this is done, the trace that get's created will live in the same context as the chat client calls, allowing you to see the end-to-end flow of your application. For Foundry, there is a method in the Foundry project client to get the telemetry url for your project, the `.setup_foundry_telemetry()` method in the `FoundryChatClient` class will use this url to configure telemetry and you then do not have to import and call `setup_telemetry()` manually. +Because of the way OpenTelemetry works, you can only call `setup_telemetry()` once per application run, so make sure you do that in the right place. + +### [03a](./03a-generic_agent.py) and [03b](./03b-foundry_agent.py) Agents: +These two samples show how to setup telemetry when using the Agent Framework's agent abstraction layer. They are similar to the chat client samples, but also show how to create an agent and invoke it. The same rules apply for setting up telemetry, you can either call `setup_telemetry()` manually, or use the `setup_foundry_telemetry()` method in the `FoundryChatClient` class. + +### [04 - workflow](./04-workflow.py) Workflow: +This sample shows how to setup telemetry when using the Agent Framework's workflow execution engine. It demonstrates a simple workflow scenario with telemetry. + + +## Running the samples 1. Open a terminal and navigate to this folder: `python/samples/getting_started/telemetry/`. This is necessary for the `.env` file to be read correctly. 2. Create a `.env` file if one doesn't already exist in this folder. Please refer to the [example file](./.env.example). > Note that `CONNECTION_STRING` and `SAMPLE_OTLP_ENDPOINT` are optional. If you don't configure them, everything will get outputted to the console. - > Set `AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS=true` to enable basic telemetry and `AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE=true` to include sensitive information like prompts and responses. - > Set `AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS=true` to enable workflow telemetry for the workflow samples. + > Set `AGENT_FRAMEWORK_ENABLE_OTEL=true` to enable basic telemetry and `AGENT_FRAMEWORK_ENABLE_SENSITIVE_DATA=true` to include sensitive information like prompts and responses. > Sensitive information should only be enabled in a development or test environment. It is not recommended to enable this in production environments as it may expose sensitive data. -3. Activate your python virtual environment, and then run `python scenarios.py`, `python interactive.py`, `python agent.py`, or `python workflow.py`. - + > Set `AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL=true` to enable workflow telemetry for the workflow samples. +3. Activate your python virtual environment, and then run `python 01-zero_code.py` or others. > This will output the Operation/Trace ID, which can be used later for filtering. -### Scenarios - -This sample includes multiple applications demonstrating Agent Framework telemetry: - -#### scenarios.py - -Organized into specific scenarios where the framework will generate useful telemetry data: - -- `chat_client`: This is when a chat client is invoked directly (i.e. not streaming) with a weather tool function. **Information about the call to the underlying model and tool usage will be recorded**. -- `chat_client_stream`: This is when a chat client is invoked with streaming enabled and a weather tool function. **Information about the streaming call to the underlying model and tool usage will be recorded**. -- `ai_function`: This is when an AI function (`get_weather`) is invoked directly. **Information about the AI function and the call to the underlying model will be recorded**. - -By default, running `python scenarios.py` will run all three scenarios. To run individual scenarios, use the `--scenario` command line argument. For example, `python scenarios.py --scenario chat_client`. For more information, please run `python scenarios.py -h`. - -#### interactive.py - -An interactive chat application that demonstrates telemetry collection in a conversational context. This sample includes the same `get_weather` tool function and allows for multi-turn conversations. Run `python interactive.py` and start chatting. Type 'exit' to quit the application. This sample only logs at the `WARNING` level, so you will not see as much telemetry data as in the `scenarios.py` sample. - -#### agent.py - -A sample demonstrating Agent Framework telemetry collection for agent-based workflows. This shows how telemetry is captured when using the Agent Framework's agent abstraction layer, including agent initialization, message processing, and tool execution within an agent context. - -By default, running `python agent.py` will run all agent scenarios. To run individual scenarios, use the `--scenario` command line argument. For example, `python agent.py --scenario basic`. For more information, please run `python agent.py -h`. - -#### workflow.py - -A sample demonstrating workflow telemetry collection for the Agent Framework's workflow execution engine. This includes two scenarios: - -- `sequential`: A simple sequential workflow that processes text through two connected executors (uppercase conversion followed by text reversal). **Information about workflow execution, executor processing, and message passing between executors will be recorded**. -- `sub_workflow`: A more complex scenario demonstrating sub-workflow patterns with a parent workflow orchestrating multiple text processing tasks via sub-workflows. **Information about parent workflow execution, sub-workflow invocation, and cross-workflow communication will be recorded**. - -By default, running `python workflow.py` will run all workflow scenarios. To run individual scenarios, use the `--scenario` command line argument. For example, `python workflow.py --scenario sequential`. For more information, please run `python workflow.py -h`. - ## Application Insights/Azure Monitor ### Logs and traces @@ -151,13 +142,13 @@ This will start the dashboard with: Make sure your `.env` file includes the OTLP endpoint: ```bash -OTLP_ENDPOINT=http://localhost:4317 +AGENT_FRAMEWORK_OTLP_ENDPOINT=http://localhost:4317 ``` Or set it as an environment variable when running your samples: ```bash -OTLP_ENDPOINT=http://localhost:4317 python scenarios.py +AGENT_FRAMEWORK_ENABLE_OTEL=true AGENT_FRAMEWORK_OTLP_ENDPOINT=http://localhost:4317 python 01-zero_code.py ``` ### Viewing telemetry data @@ -170,138 +161,4 @@ Once your sample finishes running, navigate to in a web You won't have to deploy an Application Insights resource or install Docker to run Aspire Dashboard if you choose to inspect telemetry data in a console. However, it is difficult to navigate through all the spans and logs produced, so **this method is only recommended when you are just getting started**. -We recommend you to get started with the `chat_client` scenario as this generates the least amount of telemetry data. Below is similar to what you will see when you run `python scenarios.py --scenario chat_client`: - -```Json -{ - "name": "chat.completions gpt-4o", - "context": { - "trace_id": "0xbda1d9efcd65435653d18fa37aef7dd3", - "span_id": "0xcd443e1917510385", - "trace_state": "[]" - }, - "kind": "SpanKind.INTERNAL", - "parent_id": "0xeca0a2ca7b7a8191", - "start_time": "2024-09-09T23:13:14.625156Z", - "end_time": "2024-09-09T23:13:17.311909Z", - "status": { - "status_code": "UNSET" - }, - "attributes": { - "gen_ai.operation.name": "chat.completions", - "gen_ai.system": "openai", - "gen_ai.request.model": "gpt-4o", - "gen_ai.response.id": "chatcmpl-A5hrG13nhtFsOgx4ziuoskjNscHtT", - "gen_ai.response.finish_reason": "FinishReason.STOP", - "gen_ai.response.prompt_tokens": 16, - "gen_ai.response.completion_tokens": 28 - }, - "events": [ - { - "name": "gen_ai.content.prompt", - "timestamp": "2024-09-09T23:13:14.625156Z", - "attributes": { - "gen_ai.prompt": "[{\"role\": \"user\", \"content\": \"Why is the sky blue in one sentence?\"}]" - } - }, - { - "name": "gen_ai.content.completion", - "timestamp": "2024-09-09T23:13:17.311909Z", - "attributes": { - "gen_ai.completion": "[{\"role\": \"assistant\", \"content\": \"The sky appears blue because molecules in the Earth's atmosphere scatter shorter wavelengths of sunlight, such as blue, more effectively than longer wavelengths like red.\"}]" - } - } - ], - "links": [], - "resource": { - "attributes": { - "telemetry.sdk.language": "python", - "telemetry.sdk.name": "opentelemetry", - "telemetry.sdk.version": "1.26.0", - "service.name": "TelemetryExample" - }, - "schema_url": "" - } -} -{ - "name": "Scenario: Chat Client", - "context": { - "trace_id": "0xbda1d9efcd65435653d18fa37aef7dd3", - "span_id": "0xeca0a2ca7b7a8191", - "trace_state": "[]" - }, - "kind": "SpanKind.INTERNAL", - "parent_id": "0x48af7ad55f2f64b5", - "start_time": "2024-09-09T23:13:14.625156Z", - "end_time": "2024-09-09T23:13:17.312910Z", - "status": { - "status_code": "UNSET" - }, - "attributes": {}, - "events": [], - "links": [], - "resource": { - "attributes": { - "telemetry.sdk.language": "python", - "telemetry.sdk.name": "opentelemetry", - "telemetry.sdk.version": "1.26.0", - "service.name": "TelemetryExample" - }, - "schema_url": "" - } -} -{ - "name": "Scenario's", - "context": { - "trace_id": "0xbda1d9efcd65435653d18fa37aef7dd3", - "span_id": "0x48af7ad55f2f64b5", - "trace_state": "[]" - }, - "kind": "SpanKind.INTERNAL", - "parent_id": null, - "start_time": "2024-09-09T23:13:13.840481Z", - "end_time": "2024-09-09T23:13:17.312910Z", - "status": { - "status_code": "UNSET" - }, - "attributes": {}, - "events": [], - "links": [], - "resource": { - "attributes": { - "telemetry.sdk.language": "python", - "telemetry.sdk.name": "opentelemetry", - "telemetry.sdk.version": "1.26.0", - "service.name": "TelemetryExample" - }, - "schema_url": "" - } -} -{ - "body": "Agent Framework usage: CompletionUsage(completion_tokens=28, prompt_tokens=16, total_tokens=44)", - "severity_number": "", - "severity_text": "INFO", - "attributes": { - "code.filepath": "/path/to/agent_framework/openai/chat_client.py", - "code.function": "store_usage", - "code.lineno": 81 - }, - "dropped_attributes": 0, - "timestamp": "2024-09-09T23:13:17.311909Z", - "observed_timestamp": "2024-09-09T23:13:17.311909Z", - "trace_id": "0xbda1d9efcd65435653d18fa37aef7dd3", - "span_id": "0xcd443e1917510385", - "trace_flags": 1, - "resource": { - "attributes": { - "telemetry.sdk.language": "python", - "telemetry.sdk.name": "opentelemetry", - "telemetry.sdk.version": "1.26.0", - "service.name": "TelemetryExample" - }, - "schema_url": "" - } -} -``` - -In the output, you will find three spans: `Scenario's`, `Scenario: Chat Client`, and `chat.completions gpt-4o`, each representing a different layer in the sample. In particular, `chat.completions gpt-4o` is generated by the chat client. Inside it, you will find information about the call, such as the timestamp of the operation, the response id and the finish reason. You will also find sensitive information such as the prompt and response to and from the model (only if you have `AGENT_FRAMEWORK__GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE` set to true). If you use Application Insights or Aspire Dashboard, these information will be available to you in an interactive UI. +Use the guides from OpenTelemetry to setup exporters for [the console](https://opentelemetry.io/docs/languages/python/getting-started/), or use [manual_setup_console_output](./manual_setup_console_output.py) as a reference, just know that there are a lot of options you can setup and this is not a comprehensive example. diff --git a/python/samples/getting_started/telemetry/agent.py b/python/samples/getting_started/telemetry/agent.py deleted file mode 100644 index 6f9d59b8dd..0000000000 --- a/python/samples/getting_started/telemetry/agent.py +++ /dev/null @@ -1,190 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -# type: ignore -import asyncio -import logging -from random import randint -from typing import Annotated - -from agent_framework import ChatAgent -from agent_framework.openai import OpenAIChatClient -from azure.monitor.opentelemetry import configure_azure_monitor -from opentelemetry import trace -from opentelemetry._logs import set_logger_provider -from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter -from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter -from opentelemetry.metrics import set_meter_provider -from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler -from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter -from opentelemetry.sdk.metrics import MeterProvider -from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader -from opentelemetry.sdk.metrics.view import DropAggregation, View -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter -from opentelemetry.semconv.attributes import service_attributes -from opentelemetry.trace import SpanKind, set_tracer_provider -from pydantic import Field -from pydantic_settings import BaseSettings - - -class TelemetrySampleSettings(BaseSettings): - """Settings for the telemetry sample application. - - Optional settings are: - - connection_string: str - The connection string for the Application Insights resource. - This value can be found in the Overview section when examining - your resource from the Azure portal. - (Env var CONNECTION_STRING) - - otlp_endpoint: str - The OTLP endpoint to send telemetry data to. - Depending on the exporter used, you may find this value in different places. - (Env var OTLP_ENDPOINT) - - If no connection string or OTLP endpoint is provided, the telemetry data will be - exported to the console. - """ - - connection_string: str | None = None - otlp_endpoint: str | None = None - - -# Load settings -settings = TelemetrySampleSettings() - -# Create a resource to represent the service/sample -resource = Resource.create({service_attributes.SERVICE_NAME: "TelemetryExample"}) - -# Define the scenarios that can be run -SCENARIOS = ["ai_service", "kernel_function", "auto_function_invocation", "all"] - -if settings.connection_string: - configure_azure_monitor( - connection_string=settings.connection_string, enable_live_metrics=True, logger_name="agent_framework" - ) - - -def set_up_logging(): - class LogFilter(logging.Filter): - """A filter to not process records from several subpackages.""" - - # These are the namespaces that we want to exclude from logging for the purposes of this demo. - namespaces_to_exclude: list[str] = [ - "httpx", - "openai", - ] - - def filter(self, record): - return not any([record.name.startswith(namespace) for namespace in self.namespaces_to_exclude]) - - exporters = [] - if settings.otlp_endpoint: - exporters.append(OTLPLogExporter(endpoint=settings.otlp_endpoint)) - if not exporters: - exporters.append(ConsoleLogExporter()) - - # Create and set a global logger provider for the application. - logger_provider = LoggerProvider(resource=resource) - # Log processors are initialized with an exporter which is responsible - # for sending the telemetry data to a particular backend. - for log_exporter in exporters: - logger_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter)) - # Sets the global default logger provider - set_logger_provider(logger_provider) - - # Create a logging handler to write logging records, in OTLP format, to the exporter. - handler = LoggingHandler() - handler.addFilter(LogFilter()) - # Attach the handler to the root logger. `getLogger()` with no arguments returns the root logger. - # Events from all child loggers will be processed by this handler. - logger = logging.getLogger() - logger.addHandler(handler) - # Set the logging level to INFO. - logger.setLevel(logging.INFO) - - -def set_up_tracing(): - exporters = [] - if settings.otlp_endpoint: - exporters.append(OTLPSpanExporter(endpoint=settings.otlp_endpoint)) - if not exporters: - exporters.append(ConsoleSpanExporter()) - - # Initialize a trace provider for the application. This is a factory for creating tracers. - tracer_provider = TracerProvider(resource=resource) - # Span processors are initialized with an exporter which is responsible - # for sending the telemetry data to a particular backend. - for exporter in exporters: - tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) - # Sets the global default tracer provider - set_tracer_provider(tracer_provider) - - -def set_up_metrics(): - exporters = [] - if settings.otlp_endpoint: - exporters.append(OTLPMetricExporter(endpoint=settings.otlp_endpoint)) - if not exporters: - exporters.append(ConsoleMetricExporter()) - - # Initialize a metric provider for the application. This is a factory for creating meters. - metric_readers = [ - PeriodicExportingMetricReader(metric_exporter, export_interval_millis=5000) for metric_exporter in exporters - ] - meter_provider = MeterProvider( - metric_readers=metric_readers, - resource=resource, - views=[ - # Dropping all instrument names except for those starting with "agent_framework" - View(instrument_name="*", aggregation=DropAggregation()), - View(instrument_name="agent_framework*"), - ], - ) - # Sets the global default meter provider - set_meter_provider(meter_provider) - - -async def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - await asyncio.sleep(randint(0, 10) / 10.0) # Simulate a network call - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def main(): - # Set up the providers - # This must be done before any other telemetry calls - set_up_logging() - set_up_tracing() - set_up_metrics() - - tracer = trace.get_tracer("agent_framework") - with tracer.start_as_current_span("Scenario: Agent Chat", kind=SpanKind.CLIENT) as current_span: - print("Running scenario: Agent Chat") - print("Welcome to the chat, type 'exit' to quit.") - agent = ChatAgent( - chat_client=OpenAIChatClient(), - tools=get_weather, - name="WeatherAgent", - instructions="You are a weather assistant.", - ) - thread = agent.get_new_thread() - message = input("User: ") - try: - while message.lower() != "exit": - print(f"{agent.display_name}: ", end="") - async for update in agent.run_stream( - message, - thread=thread, - ): - if update.text: - print(update.text, end="") - message = input("\nUser: ") - except Exception as e: - current_span.record_exception(e) - print(f"\nError running interactive chat: {e}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/telemetry/interactive.py b/python/samples/getting_started/telemetry/interactive.py deleted file mode 100644 index 88dd294cde..0000000000 --- a/python/samples/getting_started/telemetry/interactive.py +++ /dev/null @@ -1,186 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -# type: ignore -import asyncio -import logging -from random import randint -from typing import Annotated - -from agent_framework import ChatMessage, ChatResponse, ChatResponseUpdate -from agent_framework.openai import OpenAIChatClient -from azure.monitor.opentelemetry import configure_azure_monitor -from opentelemetry import trace -from opentelemetry._logs import set_logger_provider -from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter -from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter -from opentelemetry.metrics import set_meter_provider -from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler -from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter -from opentelemetry.sdk.metrics import MeterProvider -from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader -from opentelemetry.sdk.metrics.view import DropAggregation, View -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter -from opentelemetry.semconv.attributes import service_attributes -from opentelemetry.trace import SpanKind, set_tracer_provider -from pydantic import Field -from pydantic_settings import BaseSettings - - -class TelemetrySampleSettings(BaseSettings): - """Settings for the telemetry sample application. - - Optional settings are: - - connection_string: str - The connection string for the Application Insights resource. - This value can be found in the Overview section when examining - your resource from the Azure portal. - (Env var CONNECTION_STRING) - - otlp_endpoint: str - The OTLP endpoint to send telemetry data to. - Depending on the exporter used, you may find this value in different places. - (Env var OTLP_ENDPOINT) - - If no connection string or OTLP endpoint is provided, the telemetry data will be - exported to the console. - """ - - connection_string: str | None = None - otlp_endpoint: str | None = None - - -# Load settings -settings = TelemetrySampleSettings() - -# Create a resource to represent the service/sample -resource = Resource.create({service_attributes.SERVICE_NAME: "TelemetryExample"}) - -# Define the scenarios that can be run -SCENARIOS = ["ai_service", "kernel_function", "auto_function_invocation", "all"] - -if settings.connection_string: - configure_azure_monitor( - connection_string=settings.connection_string, enable_live_metrics=True, logger_name="agent_framework" - ) - - -def set_up_logging(): - class LogFilter(logging.Filter): - """A filter to not process records from several subpackages.""" - - # These are the namespaces that we want to exclude from logging for the purposes of this demo. - namespaces_to_exclude: list[str] = [ - "httpx", - "openai", - ] - - def filter(self, record): - return not any([record.name.startswith(namespace) for namespace in self.namespaces_to_exclude]) - - exporters = [] - if settings.otlp_endpoint: - exporters.append(OTLPLogExporter(endpoint=settings.otlp_endpoint)) - if not exporters: - exporters.append(ConsoleLogExporter()) - - # Create and set a global logger provider for the application. - logger_provider = LoggerProvider(resource=resource) - # Log processors are initialized with an exporter which is responsible - # for sending the telemetry data to a particular backend. - for log_exporter in exporters: - logger_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter)) - # Sets the global default logger provider - set_logger_provider(logger_provider) - - # Create a logging handler to write logging records, in OTLP format, to the exporter. - handler = LoggingHandler() - handler.addFilter(LogFilter()) - # Attach the handler to the root logger. `getLogger()` with no arguments returns the root logger. - # Events from all child loggers will be processed by this handler. - logger = logging.getLogger() - logger.addHandler(handler) - # Set the logging level to WARNING, this will not log detailed events to the logger. - logger.setLevel(logging.WARNING) - - -def set_up_tracing(): - exporters = [] - if settings.otlp_endpoint: - exporters.append(OTLPSpanExporter(endpoint=settings.otlp_endpoint)) - if not exporters: - exporters.append(ConsoleSpanExporter()) - - # Initialize a trace provider for the application. This is a factory for creating tracers. - tracer_provider = TracerProvider(resource=resource) - # Span processors are initialized with an exporter which is responsible - # for sending the telemetry data to a particular backend. - for exporter in exporters: - tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) - # Sets the global default tracer provider - set_tracer_provider(tracer_provider) - - -def set_up_metrics(): - exporters = [] - if settings.otlp_endpoint: - exporters.append(OTLPMetricExporter(endpoint=settings.otlp_endpoint)) - if not exporters: - exporters.append(ConsoleMetricExporter()) - - # Initialize a metric provider for the application. This is a factory for creating meters. - metric_readers = [ - PeriodicExportingMetricReader(metric_exporter, export_interval_millis=5000) for metric_exporter in exporters - ] - meter_provider = MeterProvider( - metric_readers=metric_readers, - resource=resource, - views=[ - # Dropping all instrument names except for those starting with "agent_framework" - View(instrument_name="*", aggregation=DropAggregation()), - View(instrument_name="agent_framework*"), - ], - ) - # Sets the global default meter provider - set_meter_provider(meter_provider) - - -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def main(): - # Set up the providers - # This must be done before any other telemetry calls - set_up_logging() - set_up_tracing() - set_up_metrics() - - tracer = trace.get_tracer("agent_framework") - with tracer.start_as_current_span("Scenario: Interactive Chat", kind=SpanKind.CLIENT) as current_span: - print("Running scenario: Interactive Chat") - print("Welcome to the chat, type 'exit' to quit.") - client = OpenAIChatClient() - messages: list[ChatMessage] = [] - message = input("User: ") - try: - while message.lower() != "exit": - messages.append(ChatMessage(role="user", text=message)) - print("Assistant: ", end="") - updates: list[ChatResponseUpdate] = [] - async for update in client.get_streaming_response(messages, tools=get_weather): - updates.append(update) - if update.text: - print(update.text, end="") - print("") - messages.extend(ChatResponse.from_chat_response_updates(updates).messages) - message = input("User: ") - except Exception as e: - current_span.record_exception(e) - print(f"\nError running interactive chat: {e}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/telemetry/manual_setup_console_output.py b/python/samples/getting_started/telemetry/manual_setup_console_output.py new file mode 100644 index 0000000000..2a74e67f5a --- /dev/null +++ b/python/samples/getting_started/telemetry/manual_setup_console_output.py @@ -0,0 +1,114 @@ +# Copyright (c) Microsoft. All rights reserved. +# type: ignore +import asyncio +import logging +from random import randint +from typing import Annotated + +from agent_framework.openai import OpenAIChatClient +from opentelemetry._logs import set_logger_provider +from opentelemetry.metrics import set_meter_provider +from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter +from opentelemetry.semconv.resource import ResourceAttributes +from opentelemetry.trace import set_tracer_provider +from pydantic import Field + +""" +This sample shows how to manually set up OpenTelemetry to log to the console. +And this can also be used as a reference for more complex telemetry setups. +""" + +resource = Resource.create({ResourceAttributes.SERVICE_NAME: "ManualSetup"}) + + +def setup_console_telemetry(): + # Create and set a global logger provider for the application. + logger_provider = LoggerProvider(resource=resource) + # Log processors are initialized with an exporter which is responsible + logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter())) + # Sets the global default logger provider + set_logger_provider(logger_provider) + + # Create a logging handler to write logging records, in OTLP format, to the exporter. + handler = LoggingHandler() + # Attach the handler to the root logger. `getLogger()` with no arguments returns the root logger. + # Events from all child loggers will be processed by this handler. + logger = logging.getLogger() + logger.addHandler(handler) + # Set the logging level to NOTSET to allow all records to be processed by the handler. + logger.setLevel(logging.NOTSET) + # Initialize a trace provider for the application. This is a factory for creating tracers. + tracer_provider = TracerProvider(resource=resource) + # Span processors are initialized with an exporter which is responsible + # for sending the telemetry data to a particular backend. + tracer_provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter())) + # Sets the global default tracer provider + set_tracer_provider(tracer_provider) + # Initialize a metric provider for the application. This is a factory for creating meters. + meter_provider = MeterProvider( + metric_readers=[PeriodicExportingMetricReader(ConsoleMetricExporter(), export_interval_millis=5000)], + resource=resource, + ) + # Sets the global default meter provider + set_meter_provider(meter_provider) + + +async def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + await asyncio.sleep(randint(0, 10) / 10.0) # Simulate a network call + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def run_chat_client() -> None: + """Run an AI service. + + This function runs an AI service and prints the output. + Telemetry will be collected for the service execution behind the scenes, + and the traces will be sent to the configured telemetry backend. + + The telemetry will include information about the AI service execution. + + Args: + stream: Whether to use streaming for the plugin + + Remarks: + When function calling is outside the open telemetry loop + each of the call to the model is handled as a seperate span, + while when the open telemetry is put last, a single span + is shown, which might include one or more rounds of function calling. + + So for the scenario below, you should see the following: + + 2 spans with gen_ai.operation.name=chat + The first has finish_reason "tool_calls" + The second has finish_reason "stop" + 2 spans with gen_ai.operation.name=execute_tool + + """ + client = OpenAIChatClient() + message = "What's the weather in Amsterdam and in Paris?" + print(f"User: {message}") + print("Assistant: ", end="") + async for chunk in client.get_streaming_response(message, tools=get_weather): + if str(chunk): + print(str(chunk), end="") + print("") + + +async def main(): + """Run the selected scenario(s).""" + setup_console_telemetry() + await run_chat_client() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/telemetry/scenarios.py b/python/samples/getting_started/telemetry/scenarios.py deleted file mode 100644 index 044260d8ca..0000000000 --- a/python/samples/getting_started/telemetry/scenarios.py +++ /dev/null @@ -1,249 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -# type: ignore -import argparse -import asyncio -import logging -from random import randint -from typing import Annotated, Literal - -from agent_framework import ai_function -from agent_framework.openai import OpenAIChatClient -from azure.monitor.opentelemetry import configure_azure_monitor -from opentelemetry import trace -from opentelemetry._logs import set_logger_provider -from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter -from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter -from opentelemetry.metrics import set_meter_provider -from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler -from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter -from opentelemetry.sdk.metrics import MeterProvider -from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader -from opentelemetry.sdk.metrics.view import DropAggregation, View -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter -from opentelemetry.semconv.attributes import service_attributes -from opentelemetry.trace import SpanKind, set_tracer_provider -from opentelemetry.trace.span import format_trace_id -from pydantic import Field -from pydantic_settings import BaseSettings - - -class TelemetrySampleSettings(BaseSettings): - """Settings for the telemetry sample application. - - Optional settings are: - - connection_string: str - The connection string for the Application Insights resource. - This value can be found in the Overview section when examining - your resource from the Azure portal. - (Env var CONNECTION_STRING) - - otlp_endpoint: str - The OTLP endpoint to send telemetry data to. - Depending on the exporter used, you may find this value in different places. - (Env var OTLP_ENDPOINT) - - If no connection string or OTLP endpoint is provided, the telemetry data will be - exported to the console. - """ - - connection_string: str | None = None - otlp_endpoint: str | None = None - - -# Load settings -settings = TelemetrySampleSettings() - -# Create a resource to represent the service/sample -resource = Resource.create({service_attributes.SERVICE_NAME: "TelemetryExample"}) - -# Define the scenarios that can be run -SCENARIOS = ["chat_client", "chat_client_stream", "ai_function", "all"] - -if settings.connection_string: - configure_azure_monitor( - connection_string=settings.connection_string, - enable_live_metrics=True, - logger_name="agent_framework", - ) - - -def set_up_logging(): - class LogFilter(logging.Filter): - """A filter to not process records from several subpackages.""" - - # These are the namespaces that we want to exclude from logging for the purposes of this demo. - namespaces_to_exclude: list[str] = [ - "httpx", - "openai", - ] - - def filter(self, record): - return not any([record.name.startswith(namespace) for namespace in self.namespaces_to_exclude]) - - exporters = [] - if settings.otlp_endpoint: - exporters.append(OTLPLogExporter(endpoint=settings.otlp_endpoint)) - if not exporters: - exporters.append(ConsoleLogExporter()) - - # Create and set a global logger provider for the application. - logger_provider = LoggerProvider(resource=resource) - # Log processors are initialized with an exporter which is responsible - # for sending the telemetry data to a particular backend. - for log_exporter in exporters: - logger_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter)) - # Sets the global default logger provider - set_logger_provider(logger_provider) - - # Create a logging handler to write logging records, in OTLP format, to the exporter. - handler = LoggingHandler() - handler.addFilter(LogFilter()) - # Attach the handler to the root logger. `getLogger()` with no arguments returns the root logger. - # Events from all child loggers will be processed by this handler. - logger = logging.getLogger() - logger.addHandler(handler) - # Set the logging level to NOTSET to allow all records to be processed by the handler. - logger.setLevel(logging.NOTSET) - - -def set_up_tracing(): - exporters = [] - if settings.otlp_endpoint: - exporters.append(OTLPSpanExporter(endpoint=settings.otlp_endpoint)) - if not exporters: - exporters.append(ConsoleSpanExporter()) - - # Initialize a trace provider for the application. This is a factory for creating tracers. - tracer_provider = TracerProvider(resource=resource) - # Span processors are initialized with an exporter which is responsible - # for sending the telemetry data to a particular backend. - for exporter in exporters: - tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) - # Sets the global default tracer provider - set_tracer_provider(tracer_provider) - - -def set_up_metrics(): - exporters = [] - if settings.otlp_endpoint: - exporters.append(OTLPMetricExporter(endpoint=settings.otlp_endpoint)) - if not exporters: - exporters.append(ConsoleMetricExporter()) - - # Initialize a metric provider for the application. This is a factory for creating meters. - metric_readers = [ - PeriodicExportingMetricReader(metric_exporter, export_interval_millis=5000) for metric_exporter in exporters - ] - meter_provider = MeterProvider( - metric_readers=metric_readers, - resource=resource, - views=[ - # Dropping all instrument names except for those starting with "agent_framework" - View(instrument_name="*", aggregation=DropAggregation()), - View(instrument_name="agent_framework*"), - ], - ) - # Sets the global default meter provider - set_meter_provider(meter_provider) - - -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def run_chat_client(stream: bool = False) -> None: - """Run an AI service. - - This function runs an AI service and prints the output. - Telemetry will be collected for the service execution behind the scenes, - and the traces will be sent to the configured telemetry backend. - - The telemetry will include information about the AI service execution. - - Args: - stream (bool): Whether to use streaming for the plugin - """ - - tracer = trace.get_tracer(__name__) - with tracer.start_as_current_span( - "Scenario: Chat Client Stream" if stream else "Scenario: Chat Client", kind=SpanKind.CLIENT - ) as current_span: - print("Running scenario: Chat Client" if not stream else "Running scenario: Chat Client Stream") - try: - client = OpenAIChatClient() - message = "What's the weather in Amsterdam and in Paris?" - print(f"User: {message}") - if stream: - print("Assistant: ", end="") - async for chunk in client.get_streaming_response(message, tools=get_weather): - if str(chunk): - print(str(chunk), end="") - print("") - else: - response = await client.get_response(message, tools=get_weather) - print(f"Assistant: {response}") - except Exception as e: - current_span.record_exception(e) - print(f"Error running AI service: {e}") - - -async def run_ai_function() -> None: - """Run a AI function. - - This function runs a AI function and prints the output. - Telemetry will be collected for the function execution behind the scenes, - and the traces will be sent to the configured telemetry backend. - - The telemetry will include information about the AI function execution - and the AI service execution. - """ - - tracer = trace.get_tracer(__name__) - with tracer.start_as_current_span("Scenario: AI Function", kind=SpanKind.CLIENT) as current_span: - print("Running scenario: AI Function") - try: - func = ai_function(get_weather) - weather = await func.invoke(location="Amsterdam") - print(f"Weather in Amsterdam:\n{weather}") - except Exception as e: - current_span.record_exception(e) - print(f"Error running kernel plugin: {e}") - - -async def main(scenario: Literal["chat_client", "chat_client_stream", "ai_function", "all"] = "all"): - # Set up the providers - # This must be done before any other telemetry calls - set_up_logging() - set_up_tracing() - set_up_metrics() - - tracer = trace.get_tracer("agent_framework") - with tracer.start_as_current_span("Scenario's", kind=SpanKind.CLIENT) as current_span: - print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - - # Scenarios where telemetry is collected in the SDK, from the most basic to the most complex. - if scenario == "chat_client" or scenario == "all": - await run_chat_client(stream=False) - if scenario == "chat_client_stream" or scenario == "all": - await run_chat_client(stream=True) - if scenario == "ai_function" or scenario == "all": - await run_ai_function() - - -if __name__ == "__main__": - arg_parser = argparse.ArgumentParser() - - arg_parser.add_argument( - "--scenario", - type=str, - choices=SCENARIOS, - default="all", - help="The scenario to run. Default is all.", - ) - - args = arg_parser.parse_args() - asyncio.run(main(args.scenario)) diff --git a/python/samples/getting_started/telemetry/workflow.py b/python/samples/getting_started/telemetry/workflow.py deleted file mode 100644 index 830e2794cb..0000000000 --- a/python/samples/getting_started/telemetry/workflow.py +++ /dev/null @@ -1,253 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -# type: ignore -import asyncio -import logging -from typing import Any - -from agent_framework.workflow import ( - Executor, - WorkflowBuilder, - WorkflowCompletedEvent, - WorkflowContext, - handler, -) -from azure.monitor.opentelemetry import configure_azure_monitor -from opentelemetry import trace -from opentelemetry._logs import set_logger_provider -from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter -from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter -from opentelemetry.metrics import set_meter_provider -from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler -from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter -from opentelemetry.sdk.metrics import MeterProvider -from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader -from opentelemetry.sdk.metrics.view import DropAggregation, View -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter -from opentelemetry.semconv.attributes import service_attributes -from opentelemetry.trace import SpanKind, set_tracer_provider -from opentelemetry.trace.span import format_trace_id -from pydantic_settings import BaseSettings - -"""Telemetry sample demonstrating OpenTelemetry integration with Agent Framework workflows. - -This sample runs a simple sequential workflow with telemetry collection, -showing telemetry collection for workflow execution, executor processing, -and message publishing between executors. -""" - - -class TelemetrySampleSettings(BaseSettings): - """Settings for the telemetry sample application. - - Optional settings are: - - connection_string: str - The connection string for the Application Insights resource. - This value can be found in the Overview section when examining - your resource from the Azure portal. - (Env var CONNECTION_STRING) - - otlp_endpoint: str - The OTLP endpoint to send telemetry data to. - Depending on the exporter used, you may find this value in different places. - (Env var OTLP_ENDPOINT) - - If no connection string or OTLP endpoint is provided, the telemetry data will be - exported to the console. - """ - - connection_string: str | None = None - otlp_endpoint: str | None = None - - -# Load settings -settings = TelemetrySampleSettings() - -# Create a resource to represent the service/sample -resource = Resource.create({service_attributes.SERVICE_NAME: "WorkflowTelemetryExample"}) - -if settings.connection_string: - configure_azure_monitor( - connection_string=settings.connection_string, - enable_live_metrics=True, - logger_name="agent_framework", - ) - - -def set_up_logging(): - class LogFilter(logging.Filter): - """A filter to not process records from several subpackages.""" - - # These are the namespaces that we want to exclude from logging for the purposes of this demo. - namespaces_to_exclude: list[str] = [ - "httpx", - "openai", - ] - - def filter(self, record): - return not any([record.name.startswith(namespace) for namespace in self.namespaces_to_exclude]) - - exporters = [] - if settings.otlp_endpoint: - exporters.append(OTLPLogExporter(endpoint=settings.otlp_endpoint)) - if not exporters: - exporters.append(ConsoleLogExporter()) - - # Create and set a global logger provider for the application. - logger_provider = LoggerProvider(resource=resource) - # Log processors are initialized with an exporter which is responsible - # for sending the telemetry data to a particular backend. - for log_exporter in exporters: - logger_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter)) - # Sets the global default logger provider - set_logger_provider(logger_provider) - - # Create a logging handler to write logging records, in OTLP format, to the exporter. - handler = LoggingHandler() - handler.addFilter(LogFilter()) - # Attach the handler to the root logger. `getLogger()` with no arguments returns the root logger. - # Events from all child loggers will be processed by this handler. - logger = logging.getLogger() - logger.addHandler(handler) - # Set the logging level to NOTSET to allow all records to be processed by the handler. - logger.setLevel(logging.NOTSET) - - -def set_up_tracing(): - exporters = [] - if settings.otlp_endpoint: - exporters.append(OTLPSpanExporter(endpoint=settings.otlp_endpoint)) - if not exporters: - exporters.append(ConsoleSpanExporter()) - - # Initialize a trace provider for the application. This is a factory for creating tracers. - tracer_provider = TracerProvider(resource=resource) - # Span processors are initialized with an exporter which is responsible - # for sending the telemetry data to a particular backend. - for exporter in exporters: - tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) - # Sets the global default tracer provider - set_tracer_provider(tracer_provider) - - -def set_up_metrics(): - exporters = [] - if settings.otlp_endpoint: - exporters.append(OTLPMetricExporter(endpoint=settings.otlp_endpoint)) - if not exporters: - exporters.append(ConsoleMetricExporter()) - - # Initialize a metric provider for the application. This is a factory for creating meters. - metric_readers = [ - PeriodicExportingMetricReader(metric_exporter, export_interval_millis=5000) for metric_exporter in exporters - ] - meter_provider = MeterProvider( - metric_readers=metric_readers, - resource=resource, - views=[ - # Dropping all instrument names except for those starting with "agent_framework" - View(instrument_name="*", aggregation=DropAggregation()), - View(instrument_name="agent_framework*"), - ], - ) - # Sets the global default meter provider - set_meter_provider(meter_provider) - - -# Executors for sequential workflow -class UpperCaseExecutor(Executor): - """An executor that converts text to uppercase.""" - - @handler - async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None: - """Execute the task by converting the input string to uppercase.""" - print(f"UpperCaseExecutor: Processing '{text}'") - result = text.upper() - print(f"UpperCaseExecutor: Result '{result}'") - - # Send the result to the next executor in the workflow. - await ctx.send_message(result) - - -class ReverseTextExecutor(Executor): - """An executor that reverses text.""" - - @handler - async def reverse_text(self, text: str, ctx: WorkflowContext[Any]) -> None: - """Execute the task by reversing the input string.""" - print(f"ReverseTextExecutor: Processing '{text}'") - result = text[::-1] - print(f"ReverseTextExecutor: Result '{result}'") - - # Send the result with a workflow completion event. - await ctx.add_event(WorkflowCompletedEvent(result)) - - -async def run_sequential_workflow() -> None: - """Run a simple sequential workflow demonstrating telemetry collection. - - This workflow processes a string through two executors in sequence: - 1. UpperCaseExecutor converts the input to uppercase - 2. ReverseTextExecutor reverses the string and completes the workflow - - Telemetry data collected includes: - - Overall workflow execution spans - - Individual executor processing spans - - Message publishing between executors - - Workflow completion events - """ - - tracer = trace.get_tracer(__name__) - with tracer.start_as_current_span("Scenario: Sequential Workflow", kind=SpanKind.CLIENT) as current_span: - print("Running scenario: Sequential Workflow") - try: - # Step 1: Create the executors. - upper_case_executor = UpperCaseExecutor(id="upper_case_executor") - reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor") - - # Step 2: Build the workflow with the defined edges. - workflow = ( - WorkflowBuilder() - .add_edge(upper_case_executor, reverse_text_executor) - .set_start_executor(upper_case_executor) - .build() - ) - - # Step 3: Run the workflow with an initial message. - input_text = "hello world" - print(f"Starting workflow with input: '{input_text}'") - - completion_event = None - async for event in workflow.run_stream(input_text): - print(f"Event: {event}") - if isinstance(event, WorkflowCompletedEvent): - # The WorkflowCompletedEvent contains the final result. - completion_event = event - - if completion_event: - print(f"Workflow completed with result: '{completion_event.data}'") - else: - print("Workflow completed without a completion event") - - except Exception as e: - current_span.record_exception(e) - print(f"Error running workflow: {e}") - - -async def main(): - """Run the telemetry sample with a simple sequential workflow.""" - # Set up the providers - # This must be done before any other telemetry calls - set_up_logging() - set_up_tracing() - set_up_metrics() - - tracer = trace.get_tracer("agent_framework") - with tracer.start_as_current_span("Sequential Workflow Scenario", kind=SpanKind.CLIENT) as current_span: - print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - - # Run the sequential workflow scenario - await run_sequential_workflow() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/workflow/agents/azure_chat_agents_streaming.py b/python/samples/getting_started/workflow/agents/azure_chat_agents_streaming.py index 97d563215a..4f39385a92 100644 --- a/python/samples/getting_started/workflow/agents/azure_chat_agents_streaming.py +++ b/python/samples/getting_started/workflow/agents/azure_chat_agents_streaming.py @@ -64,7 +64,7 @@ async def main(): # AgentRunUpdateEvent contains incremental text deltas from the underlying agent. # Print a prefix when the executor changes, then append updates on the same line. eid = event.executor_id - if eid != last_executor_id: + if eid != last_executor_id: # type: ignore[reportUnnecessaryComparison] if last_executor_id is not None: print() print(f"{eid}:", end=" ", flush=True) diff --git a/python/uv.lock b/python/uv.lock index 1f995f10f7..a987125e3b 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -2,11 +2,14 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'darwin'", "python_full_version < '3.11' and sys_platform == 'darwin'", - "python_full_version >= '3.11' and sys_platform == 'linux'", + "python_full_version >= '3.13' and sys_platform == 'linux'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'linux'", "python_full_version < '3.11' and sys_platform == 'linux'", - "python_full_version >= '3.11' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", "python_full_version < '3.11' and sys_platform == 'win32'", ] supported-markers = [ @@ -42,9 +45,12 @@ name = "agent-framework" version = "0.1.0b1" source = { editable = "packages/main" } dependencies = [ + { name = "azure-monitor-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mcp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-sdk", 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 = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -71,9 +77,12 @@ requires-dist = [ { name = "agent-framework-foundry", marker = "extra == 'foundry'", editable = "packages/foundry" }, { name = "agent-framework-runtime", marker = "extra == 'runtime'", editable = "packages/runtime" }, { name = "agent-framework-workflow", marker = "extra == 'workflow'", editable = "packages/workflow" }, + { name = "azure-monitor-opentelemetry", specifier = ">=1.7.0" }, + { name = "azure-monitor-opentelemetry-exporter", specifier = ">=1.0.0b41" }, { name = "mcp", specifier = ">=1.12" }, { name = "openai", specifier = ">=1.103.0" }, { name = "opentelemetry-api", specifier = "~=1.24" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.36.0" }, { name = "opentelemetry-sdk", specifier = "~=1.24" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, @@ -392,6 +401,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, ] +[[package]] +name = "asgiref" +version = "3.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/61/0aa957eec22ff70b830b22ff91f825e70e1ef732c06666a805730f28b36b/asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", size = 36870, upload-time = "2025-07-08T09:07:43.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/3c/0464dcada90d5da0e71018c04a140ad6349558afb30b3051b4264cc5b965/asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c", size = 23790, upload-time = "2025-07-08T09:07:41.548Z" }, +] + [[package]] name = "asttokens" version = "3.0.0" @@ -477,6 +498,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/78/bf94897361fdd650850f0f2e405b2293e2f12808239046232bdedf554301/azure_core-1.35.0-py3-none-any.whl", hash = "sha256:8db78c72868a58f3de8991eb4d22c4d368fae226dac1002998d6c50437e7dad1", size = 210708, upload-time = "2025-07-03T00:55:25.238Z" }, ] +[[package]] +name = "azure-core-tracing-opentelemetry" +version = "1.0.0b12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/7f/5de13a331a5f2919417819cc37dcf7c897018f02f83aa82b733e6629a6a6/azure_core_tracing_opentelemetry-1.0.0b12.tar.gz", hash = "sha256:bb454142440bae11fd9d68c7c1d67ae38a1756ce808c5e4d736730a7b4b04144", size = 26010, upload-time = "2025-03-21T00:18:37.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/5e/97a471f66935e7f89f521d0e11ae49c7f0871ca38f5c319dccae2155c8d8/azure_core_tracing_opentelemetry-1.0.0b12-py3-none-any.whl", hash = "sha256:38fd42709f1cc4bbc4f2797008b1c30a6a01617e49910c05daa3a0d0c65053ac", size = 11962, upload-time = "2025-03-21T00:18:38.581Z" }, +] + [[package]] name = "azure-identity" version = "1.24.0" @@ -493,6 +527,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/74/17428cb429e8d52f6d0d69ed685f4760a545cb0156594963a9337b53b6c9/azure_identity-1.24.0-py3-none-any.whl", hash = "sha256:9e04997cde0ab02ed66422c74748548e620b7b29361c72ce622acab0267ff7c4", size = 187890, upload-time = "2025-08-07T22:27:38.033Z" }, ] +[[package]] +name = "azure-monitor-opentelemetry" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core-tracing-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-django", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-psycopg2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-urllib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-resource-detector-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/77/be4ae57398fe54fdd97af90df32173f68f37593dc56610c7b04c1643da96/azure_monitor_opentelemetry-1.7.0.tar.gz", hash = "sha256:eba75e793a95d50f6e5bc35dd2781744e2c1a5cc801b530b688f649423f2ee00", size = 51735, upload-time = "2025-08-21T15:52:58.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/bd/b898a883f379d2b4f9bcb9473d4daac24160854d947f17219a7b9211ab34/azure_monitor_opentelemetry-1.7.0-py3-none-any.whl", hash = "sha256:937c60e9706f75c77b221979a273a27e811cc6529d6887099f53916719c66dd3", size = 26316, upload-time = "2025-08-21T15:53:00.153Z" }, +] + +[[package]] +name = "azure-monitor-opentelemetry-exporter" +version = "1.0.0b41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fixedint", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "msrest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/c3/2f18eaed17a40982ad04953ad0fa5b1a2dbc5a5c98b6d3ef68c1d7d285ae/azure_monitor_opentelemetry_exporter-1.0.0b41.tar.gz", hash = "sha256:b363e6f89c0dee16d02782a310a60d626e4c081ef49d533ff5225a40cbab12cc", size = 206710, upload-time = "2025-07-31T22:37:28.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/d4/8cc989c4eedcefb74dccdd5af2faf51e0237163f538d2b258eef7e35f33d/azure_monitor_opentelemetry_exporter-1.0.0b41-py2.py3-none-any.whl", hash = "sha256:cbba629cca53e0e33416c61e08ebaabe833e740cfbfd7f2e9151821f92c66a51", size = 162631, upload-time = "2025-07-31T22:37:29.809Z" }, +] + [[package]] name = "azure-storage-blob" version = "12.26.0" @@ -800,49 +875,49 @@ toml = [ [[package]] name = "cryptography" -version = "45.0.6" +version = "45.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/0d/d13399c94234ee8f3df384819dc67e0c5ce215fb751d567a55a1f4b028c7/cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719", size = 744949, upload-time = "2025-08-05T23:59:27.93Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980, upload-time = "2025-09-01T11:15:03.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/29/2793d178d0eda1ca4a09a7c4e09a5185e75738cc6d526433e8663b460ea6/cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74", size = 7042702, upload-time = "2025-08-05T23:58:23.464Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b6/cabd07410f222f32c8d55486c464f432808abaa1f12af9afcbe8f2f19030/cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f", size = 4206483, upload-time = "2025-08-05T23:58:27.132Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9e/f9c7d36a38b1cfeb1cc74849aabe9bf817990f7603ff6eb485e0d70e0b27/cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf", size = 4429679, upload-time = "2025-08-05T23:58:29.152Z" }, - { url = "https://files.pythonhosted.org/packages/9c/2a/4434c17eb32ef30b254b9e8b9830cee4e516f08b47fdd291c5b1255b8101/cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5", size = 4210553, upload-time = "2025-08-05T23:58:30.596Z" }, - { url = "https://files.pythonhosted.org/packages/ef/1d/09a5df8e0c4b7970f5d1f3aff1b640df6d4be28a64cae970d56c6cf1c772/cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2", size = 3894499, upload-time = "2025-08-05T23:58:32.03Z" }, - { url = "https://files.pythonhosted.org/packages/79/62/120842ab20d9150a9d3a6bdc07fe2870384e82f5266d41c53b08a3a96b34/cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08", size = 4458484, upload-time = "2025-08-05T23:58:33.526Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/1bc3634d45ddfed0871bfba52cf8f1ad724761662a0c792b97a951fb1b30/cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402", size = 4210281, upload-time = "2025-08-05T23:58:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fe/ffb12c2d83d0ee625f124880a1f023b5878f79da92e64c37962bbbe35f3f/cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42", size = 4456890, upload-time = "2025-08-05T23:58:36.923Z" }, - { url = "https://files.pythonhosted.org/packages/8c/8e/b3f3fe0dc82c77a0deb5f493b23311e09193f2268b77196ec0f7a36e3f3e/cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05", size = 4333247, upload-time = "2025-08-05T23:58:38.781Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a6/c3ef2ab9e334da27a1d7b56af4a2417d77e7806b2e0f90d6267ce120d2e4/cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453", size = 4565045, upload-time = "2025-08-05T23:58:40.415Z" }, - { url = "https://files.pythonhosted.org/packages/31/c3/77722446b13fa71dddd820a5faab4ce6db49e7e0bf8312ef4192a3f78e2f/cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159", size = 2928923, upload-time = "2025-08-05T23:58:41.919Z" }, - { url = "https://files.pythonhosted.org/packages/38/63/a025c3225188a811b82932a4dcc8457a26c3729d81578ccecbcce2cb784e/cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec", size = 3403805, upload-time = "2025-08-05T23:58:43.792Z" }, - { url = "https://files.pythonhosted.org/packages/5b/af/bcfbea93a30809f126d51c074ee0fac5bd9d57d068edf56c2a73abedbea4/cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0", size = 7020111, upload-time = "2025-08-05T23:58:45.316Z" }, - { url = "https://files.pythonhosted.org/packages/98/c6/ea5173689e014f1a8470899cd5beeb358e22bb3cf5a876060f9d1ca78af4/cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394", size = 4198169, upload-time = "2025-08-05T23:58:47.121Z" }, - { url = "https://files.pythonhosted.org/packages/ba/73/b12995edc0c7e2311ffb57ebd3b351f6b268fed37d93bfc6f9856e01c473/cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9", size = 4421273, upload-time = "2025-08-05T23:58:48.557Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6e/286894f6f71926bc0da67408c853dd9ba953f662dcb70993a59fd499f111/cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3", size = 4199211, upload-time = "2025-08-05T23:58:50.139Z" }, - { url = "https://files.pythonhosted.org/packages/de/34/a7f55e39b9623c5cb571d77a6a90387fe557908ffc44f6872f26ca8ae270/cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3", size = 3883732, upload-time = "2025-08-05T23:58:52.253Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b9/c6d32edbcba0cd9f5df90f29ed46a65c4631c4fbe11187feb9169c6ff506/cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301", size = 4450655, upload-time = "2025-08-05T23:58:53.848Z" }, - { url = "https://files.pythonhosted.org/packages/77/2d/09b097adfdee0227cfd4c699b3375a842080f065bab9014248933497c3f9/cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5", size = 4198956, upload-time = "2025-08-05T23:58:55.209Z" }, - { url = "https://files.pythonhosted.org/packages/55/66/061ec6689207d54effdff535bbdf85cc380d32dd5377173085812565cf38/cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016", size = 4449859, upload-time = "2025-08-05T23:58:56.639Z" }, - { url = "https://files.pythonhosted.org/packages/41/ff/e7d5a2ad2d035e5a2af116e1a3adb4d8fcd0be92a18032917a089c6e5028/cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3", size = 4320254, upload-time = "2025-08-05T23:58:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/82/27/092d311af22095d288f4db89fcaebadfb2f28944f3d790a4cf51fe5ddaeb/cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9", size = 4554815, upload-time = "2025-08-05T23:59:00.283Z" }, - { url = "https://files.pythonhosted.org/packages/7e/01/aa2f4940262d588a8fdf4edabe4cda45854d00ebc6eaac12568b3a491a16/cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02", size = 2912147, upload-time = "2025-08-05T23:59:01.716Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" }, - { url = "https://files.pythonhosted.org/packages/56/d2/4482d97c948c029be08cb29854a91bd2ae8da7eb9c4152461f1244dcea70/cryptography-45.0.6-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:705bb7c7ecc3d79a50f236adda12ca331c8e7ecfbea51edd931ce5a7a7c4f012", size = 3576812, upload-time = "2025-08-05T23:59:04.833Z" }, - { url = "https://files.pythonhosted.org/packages/ec/24/55fc238fcaa122855442604b8badb2d442367dfbd5a7ca4bb0bd346e263a/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:826b46dae41a1155a0c0e66fafba43d0ede1dc16570b95e40c4d83bfcf0a451d", size = 4141694, upload-time = "2025-08-05T23:59:06.66Z" }, - { url = "https://files.pythonhosted.org/packages/f9/7e/3ea4fa6fbe51baf3903806a0241c666b04c73d2358a3ecce09ebee8b9622/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cc4d66f5dc4dc37b89cfef1bd5044387f7a1f6f0abb490815628501909332d5d", size = 4375010, upload-time = "2025-08-05T23:59:08.14Z" }, - { url = "https://files.pythonhosted.org/packages/50/42/ec5a892d82d2a2c29f80fc19ced4ba669bca29f032faf6989609cff1f8dc/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f68f833a9d445cc49f01097d95c83a850795921b3f7cc6488731e69bde3288da", size = 4141377, upload-time = "2025-08-05T23:59:09.584Z" }, - { url = "https://files.pythonhosted.org/packages/e7/d7/246c4c973a22b9c2931999da953a2c19cae7c66b9154c2d62ffed811225e/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3b5bf5267e98661b9b888a9250d05b063220dfa917a8203744454573c7eb79db", size = 4374609, upload-time = "2025-08-05T23:59:11.923Z" }, - { url = "https://files.pythonhosted.org/packages/78/6d/c49ccf243f0a1b0781c2a8de8123ee552f0c8a417c6367a24d2ecb7c11b3/cryptography-45.0.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2384f2ab18d9be88a6e4f8972923405e2dbb8d3e16c6b43f15ca491d7831bd18", size = 3322156, upload-time = "2025-08-05T23:59:13.597Z" }, - { url = "https://files.pythonhosted.org/packages/61/69/c252de4ec047ba2f567ecb53149410219577d408c2aea9c989acae7eafce/cryptography-45.0.6-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fc022c1fa5acff6def2fc6d7819bbbd31ccddfe67d075331a65d9cfb28a20983", size = 3584669, upload-time = "2025-08-05T23:59:15.431Z" }, - { url = "https://files.pythonhosted.org/packages/e3/fe/deea71e9f310a31fe0a6bfee670955152128d309ea2d1c79e2a5ae0f0401/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3de77e4df42ac8d4e4d6cdb342d989803ad37707cf8f3fbf7b088c9cbdd46427", size = 4153022, upload-time = "2025-08-05T23:59:16.954Z" }, - { url = "https://files.pythonhosted.org/packages/60/45/a77452f5e49cb580feedba6606d66ae7b82c128947aa754533b3d1bd44b0/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:599c8d7df950aa68baa7e98f7b73f4f414c9f02d0e8104a30c0182a07732638b", size = 4386802, upload-time = "2025-08-05T23:59:18.55Z" }, - { url = "https://files.pythonhosted.org/packages/a3/b9/a2f747d2acd5e3075fdf5c145c7c3568895daaa38b3b0c960ef830db6cdc/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:31a2b9a10530a1cb04ffd6aa1cd4d3be9ed49f7d77a4dafe198f3b382f41545c", size = 4152706, upload-time = "2025-08-05T23:59:20.044Z" }, - { url = "https://files.pythonhosted.org/packages/81/ec/381b3e8d0685a3f3f304a382aa3dfce36af2d76467da0fd4bb21ddccc7b2/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e5b3dda1b00fb41da3af4c5ef3f922a200e33ee5ba0f0bc9ecf0b0c173958385", size = 4386740, upload-time = "2025-08-05T23:59:21.525Z" }, - { url = "https://files.pythonhosted.org/packages/0a/76/cf8d69da8d0b5ecb0db406f24a63a3f69ba5e791a11b782aeeefef27ccbb/cryptography-45.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:629127cfdcdc6806dfe234734d7cb8ac54edaf572148274fa377a7d3405b0043", size = 3331874, upload-time = "2025-08-05T23:59:23.017Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105, upload-time = "2025-09-01T11:13:59.684Z" }, + { url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799, upload-time = "2025-09-01T11:14:02.517Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504, upload-time = "2025-09-01T11:14:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542, upload-time = "2025-09-01T11:14:06.309Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e3/e7de4771a08620eef2389b86cd87a2c50326827dea5528feb70595439ce4/cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf", size = 3889244, upload-time = "2025-09-01T11:14:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/b8/bca71059e79a0bb2f8e4ec61d9c205fbe97876318566cde3b5092529faa9/cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513", size = 4461975, upload-time = "2025-09-01T11:14:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/58/67/3f5b26937fe1218c40e95ef4ff8d23c8dc05aa950d54200cc7ea5fb58d28/cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3", size = 4209082, upload-time = "2025-09-01T11:14:11.229Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397, upload-time = "2025-09-01T11:14:12.924Z" }, + { url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244, upload-time = "2025-09-01T11:14:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862, upload-time = "2025-09-01T11:14:16.185Z" }, + { url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578, upload-time = "2025-09-01T11:14:17.638Z" }, + { url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400, upload-time = "2025-09-01T11:14:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824, upload-time = "2025-09-01T11:14:20.954Z" }, + { url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233, upload-time = "2025-09-01T11:14:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075, upload-time = "2025-09-01T11:14:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517, upload-time = "2025-09-01T11:14:25.679Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/924a723299848b4c741c1059752c7cfe09473b6fd77d2920398fc26bfb53/cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3", size = 3882893, upload-time = "2025-09-01T11:14:27.1Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/4dab2ff0a871cc2d81d3ae6d780991c0192b259c35e4d83fe1de18b20c70/cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1", size = 4450132, upload-time = "2025-09-01T11:14:28.58Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/b2882b65db8fc944585d7fb00d67cf84a9cef4e77d9ba8f69082e911d0de/cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27", size = 4204086, upload-time = "2025-09-01T11:14:30.572Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383, upload-time = "2025-09-01T11:14:32.046Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186, upload-time = "2025-09-01T11:14:33.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639, upload-time = "2025-09-01T11:14:35.343Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552, upload-time = "2025-09-01T11:14:36.929Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742, upload-time = "2025-09-01T11:14:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/e42f1528ca1ea82256b835191eab1be014e0f9f934b60d98b0be8a38ed70/cryptography-45.0.7-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:de58755d723e86175756f463f2f0bddd45cc36fbd62601228a3f8761c9f58252", size = 3572442, upload-time = "2025-09-01T11:14:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/59/aa/e947693ab08674a2663ed2534cd8d345cf17bf6a1facf99273e8ec8986dc/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a20e442e917889d1a6b3c570c9e3fa2fdc398c20868abcea268ea33c024c4083", size = 4142233, upload-time = "2025-09-01T11:14:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/09b6f6a2fc43474a32b8fe259038eef1500ee3d3c141599b57ac6c57612c/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:258e0dff86d1d891169b5af222d362468a9570e2532923088658aa866eb11130", size = 4376202, upload-time = "2025-09-01T11:14:43.047Z" }, + { url = "https://files.pythonhosted.org/packages/00/f2/c166af87e95ce6ae6d38471a7e039d3a0549c2d55d74e059680162052824/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d97cf502abe2ab9eff8bd5e4aca274da8d06dd3ef08b759a8d6143f4ad65d4b4", size = 4141900, upload-time = "2025-09-01T11:14:45.089Z" }, + { url = "https://files.pythonhosted.org/packages/16/b9/e96e0b6cb86eae27ea51fa8a3151535a18e66fe7c451fa90f7f89c85f541/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:c987dad82e8c65ebc985f5dae5e74a3beda9d0a2a4daf8a1115f3772b59e5141", size = 4375562, upload-time = "2025-09-01T11:14:47.166Z" }, + { url = "https://files.pythonhosted.org/packages/36/d0/36e8ee39274e9d77baf7d0dafda680cba6e52f3936b846f0d56d64fec915/cryptography-45.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c13b1e3afd29a5b3b2656257f14669ca8fa8d7956d509926f0b130b600b50ab7", size = 3322781, upload-time = "2025-09-01T11:14:48.747Z" }, + { url = "https://files.pythonhosted.org/packages/99/4e/49199a4c82946938a3e05d2e8ad9482484ba48bbc1e809e3d506c686d051/cryptography-45.0.7-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a862753b36620af6fc54209264f92c716367f2f0ff4624952276a6bbd18cbde", size = 3584634, upload-time = "2025-09-01T11:14:50.593Z" }, + { url = "https://files.pythonhosted.org/packages/16/ce/5f6ff59ea9c7779dba51b84871c19962529bdcc12e1a6ea172664916c550/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:06ce84dc14df0bf6ea84666f958e6080cdb6fe1231be2a51f3fc1267d9f3fb34", size = 4149533, upload-time = "2025-09-01T11:14:52.091Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/b3cfbd257ac96da4b88b46372e662009b7a16833bfc5da33bb97dd5631ae/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d0c5c6bac22b177bf8da7435d9d27a6834ee130309749d162b26c3105c0795a9", size = 4385557, upload-time = "2025-09-01T11:14:53.551Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c5/8c59d6b7c7b439ba4fc8d0cab868027fd095f215031bc123c3a070962912/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:2f641b64acc00811da98df63df7d59fd4706c0df449da71cb7ac39a0732b40ae", size = 4149023, upload-time = "2025-09-01T11:14:55.022Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/05385c86d6ca9ab0b4d5bb442d2e3d85e727939a11f3e163fc776ce5eb40/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:f5414a788ecc6ee6bc58560e85ca624258a55ca434884445440a810796ea0e0b", size = 4385722, upload-time = "2025-09-01T11:14:57.319Z" }, + { url = "https://files.pythonhosted.org/packages/23/87/7ce86f3fa14bc11a5a48c30d8103c26e09b6465f8d8e9d74cf7a0714f043/cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63", size = 3332908, upload-time = "2025-09-01T11:14:58.78Z" }, ] [[package]] @@ -938,11 +1013,11 @@ wheels = [ [[package]] name = "executing" -version = "2.2.0" +version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693, upload-time = "2025-01-22T15:41:29.403Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] [[package]] @@ -963,6 +1038,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] +[[package]] +name = "fixedint" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/c6/b1b9b3f69915d51909ef6ebe6352e286ec3d6f2077278af83ec6e3cc569c/fixedint-0.1.6.tar.gz", hash = "sha256:703005d090499d41ce7ce2ee7eae8f7a5589a81acdc6b79f1728a56495f2c799", size = 12750, upload-time = "2020-06-20T22:14:16.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/6d/8f5307d26ce700a89e5a67d1e1ad15eff977211f9ed3ae90d7b0d67f4e66/fixedint-0.1.6-py3-none-any.whl", hash = "sha256:b8cf9f913735d2904deadda7a6daa9f57100599da1de57a7448ea1be75ae8c9c", size = 12702, upload-time = "2020-06-20T22:14:15.454Z" }, +] + [[package]] name = "frozenlist" version = "1.7.0" @@ -1057,6 +1141,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, +] + [[package]] name = "graphviz" version = "0.21" @@ -1117,6 +1213,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] +[[package]] +name = "grpcio" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b4/35feb8f7cab7239c5b94bd2db71abb3d6adb5f335ad8f131abb6060840b6/grpcio-1.74.0.tar.gz", hash = "sha256:80d1f4fbb35b0742d3e3d3bb654b7381cd5f015f8497279a1e9c21ba623e01b1", size = 12756048, upload-time = "2025-07-24T18:54:23.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/54/68e51a90797ad7afc5b0a7881426c337f6a9168ebab73c3210b76aa7c90d/grpcio-1.74.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:85bd5cdf4ed7b2d6438871adf6afff9af7096486fcf51818a81b77ef4dd30907", size = 5481935, upload-time = "2025-07-24T18:52:43.756Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/af817c7e9843929e93e54d09c9aee2555c2e8d81b93102a9426b36e91833/grpcio-1.74.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:68c8ebcca945efff9d86d8d6d7bfb0841cf0071024417e2d7f45c5e46b5b08eb", size = 10986796, upload-time = "2025-07-24T18:52:47.219Z" }, + { url = "https://files.pythonhosted.org/packages/d5/94/d67756638d7bb07750b07d0826c68e414124574b53840ba1ff777abcd388/grpcio-1.74.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:e154d230dc1bbbd78ad2fdc3039fa50ad7ffcf438e4eb2fa30bce223a70c7486", size = 5983663, upload-time = "2025-07-24T18:52:49.463Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/c5e4853bf42148fea8532d49e919426585b73eafcf379a712934652a8de9/grpcio-1.74.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8978003816c7b9eabe217f88c78bc26adc8f9304bf6a594b02e5a49b2ef9c11", size = 6653765, upload-time = "2025-07-24T18:52:51.094Z" }, + { url = "https://files.pythonhosted.org/packages/fd/75/a1991dd64b331d199935e096cc9daa3415ee5ccbe9f909aa48eded7bba34/grpcio-1.74.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3d7bd6e3929fd2ea7fbc3f562e4987229ead70c9ae5f01501a46701e08f1ad9", size = 6215172, upload-time = "2025-07-24T18:52:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/7cef3dbb3b073d0ce34fd507efc44ac4c9442a0ef9fba4fb3f5c551efef5/grpcio-1.74.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:136b53c91ac1d02c8c24201bfdeb56f8b3ac3278668cbb8e0ba49c88069e1bdc", size = 6329142, upload-time = "2025-07-24T18:52:54.927Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/587920f882b46e835ad96014087054655312400e2f1f1446419e5179a383/grpcio-1.74.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fe0f540750a13fd8e5da4b3eaba91a785eea8dca5ccd2bc2ffe978caa403090e", size = 7018632, upload-time = "2025-07-24T18:52:56.523Z" }, + { url = "https://files.pythonhosted.org/packages/1f/95/c70a3b15a0bc83334b507e3d2ae20ee8fa38d419b8758a4d838f5c2a7d32/grpcio-1.74.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4e4181bfc24413d1e3a37a0b7889bea68d973d4b45dd2bc68bb766c140718f82", size = 6509641, upload-time = "2025-07-24T18:52:58.495Z" }, + { url = "https://files.pythonhosted.org/packages/4b/06/2e7042d06247d668ae69ea6998eca33f475fd4e2855f94dcb2aa5daef334/grpcio-1.74.0-cp310-cp310-win32.whl", hash = "sha256:1733969040989f7acc3d94c22f55b4a9501a30f6aaacdbccfaba0a3ffb255ab7", size = 3817478, upload-time = "2025-07-24T18:53:00.128Z" }, + { url = "https://files.pythonhosted.org/packages/93/20/e02b9dcca3ee91124060b65bbf5b8e1af80b3b76a30f694b44b964ab4d71/grpcio-1.74.0-cp310-cp310-win_amd64.whl", hash = "sha256:9e912d3c993a29df6c627459af58975b2e5c897d93287939b9d5065f000249b5", size = 4493971, upload-time = "2025-07-24T18:53:02.068Z" }, + { url = "https://files.pythonhosted.org/packages/e7/77/b2f06db9f240a5abeddd23a0e49eae2b6ac54d85f0e5267784ce02269c3b/grpcio-1.74.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:69e1a8180868a2576f02356565f16635b99088da7df3d45aaa7e24e73a054e31", size = 5487368, upload-time = "2025-07-24T18:53:03.548Z" }, + { url = "https://files.pythonhosted.org/packages/48/99/0ac8678a819c28d9a370a663007581744a9f2a844e32f0fa95e1ddda5b9e/grpcio-1.74.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8efe72fde5500f47aca1ef59495cb59c885afe04ac89dd11d810f2de87d935d4", size = 10999804, upload-time = "2025-07-24T18:53:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/a2d586300d9e14ad72e8dc211c7aecb45fe9846a51e558c5bca0c9102c7f/grpcio-1.74.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a8f0302f9ac4e9923f98d8e243939a6fb627cd048f5cd38595c97e38020dffce", size = 5987667, upload-time = "2025-07-24T18:53:07.157Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/5f338bf56a7f22584e68d669632e521f0de460bb3749d54533fc3d0fca4f/grpcio-1.74.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f609a39f62a6f6f05c7512746798282546358a37ea93c1fcbadf8b2fed162e3", size = 6655612, upload-time = "2025-07-24T18:53:09.244Z" }, + { url = "https://files.pythonhosted.org/packages/82/ea/a4820c4c44c8b35b1903a6c72a5bdccec92d0840cf5c858c498c66786ba5/grpcio-1.74.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98e0b7434a7fa4e3e63f250456eaef52499fba5ae661c58cc5b5477d11e7182", size = 6219544, upload-time = "2025-07-24T18:53:11.221Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/0537630a921365928f5abb6d14c79ba4dcb3e662e0dbeede8af4138d9dcf/grpcio-1.74.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:662456c4513e298db6d7bd9c3b8df6f75f8752f0ba01fb653e252ed4a59b5a5d", size = 6334863, upload-time = "2025-07-24T18:53:12.925Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a6/85ca6cb9af3f13e1320d0a806658dca432ff88149d5972df1f7b51e87127/grpcio-1.74.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3d14e3c4d65e19d8430a4e28ceb71ace4728776fd6c3ce34016947474479683f", size = 7019320, upload-time = "2025-07-24T18:53:15.002Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/fe2beab970a1e25d2eff108b3cf4f7d9a53c185106377a3d1989216eba45/grpcio-1.74.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bf949792cee20d2078323a9b02bacbbae002b9e3b9e2433f2741c15bdeba1c4", size = 6514228, upload-time = "2025-07-24T18:53:16.999Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c2/2f9c945c8a248cebc3ccda1b7a1bf1775b9d7d59e444dbb18c0014e23da6/grpcio-1.74.0-cp311-cp311-win32.whl", hash = "sha256:55b453812fa7c7ce2f5c88be3018fb4a490519b6ce80788d5913f3f9d7da8c7b", size = 3817216, upload-time = "2025-07-24T18:53:20.564Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d1/a9cf9c94b55becda2199299a12b9feef0c79946b0d9d34c989de6d12d05d/grpcio-1.74.0-cp311-cp311-win_amd64.whl", hash = "sha256:86ad489db097141a907c559988c29718719aa3e13370d40e20506f11b4de0d11", size = 4495380, upload-time = "2025-07-24T18:53:22.058Z" }, + { url = "https://files.pythonhosted.org/packages/4c/5d/e504d5d5c4469823504f65687d6c8fb97b7f7bf0b34873b7598f1df24630/grpcio-1.74.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8533e6e9c5bd630ca98062e3a1326249e6ada07d05acf191a77bc33f8948f3d8", size = 5445551, upload-time = "2025-07-24T18:53:23.641Z" }, + { url = "https://files.pythonhosted.org/packages/43/01/730e37056f96f2f6ce9f17999af1556df62ee8dab7fa48bceeaab5fd3008/grpcio-1.74.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2918948864fec2a11721d91568effffbe0a02b23ecd57f281391d986847982f6", size = 10979810, upload-time = "2025-07-24T18:53:25.349Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/09fd100473ea5c47083889ca47ffd356576173ec134312f6aa0e13111dee/grpcio-1.74.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:60d2d48b0580e70d2e1954d0d19fa3c2e60dd7cbed826aca104fff518310d1c5", size = 5941946, upload-time = "2025-07-24T18:53:27.387Z" }, + { url = "https://files.pythonhosted.org/packages/8a/99/12d2cca0a63c874c6d3d195629dcd85cdf5d6f98a30d8db44271f8a97b93/grpcio-1.74.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3601274bc0523f6dc07666c0e01682c94472402ac2fd1226fd96e079863bfa49", size = 6621763, upload-time = "2025-07-24T18:53:29.193Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2c/930b0e7a2f1029bbc193443c7bc4dc2a46fedb0203c8793dcd97081f1520/grpcio-1.74.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:176d60a5168d7948539def20b2a3adcce67d72454d9ae05969a2e73f3a0feee7", size = 6180664, upload-time = "2025-07-24T18:53:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/db/d5/ff8a2442180ad0867717e670f5ec42bfd8d38b92158ad6bcd864e6d4b1ed/grpcio-1.74.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e759f9e8bc908aaae0412642afe5416c9f983a80499448fcc7fab8692ae044c3", size = 6301083, upload-time = "2025-07-24T18:53:32.454Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/b361d390451a37ca118e4ec7dccec690422e05bc85fba2ec72b06cefec9f/grpcio-1.74.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9e7c4389771855a92934b2846bd807fc25a3dfa820fd912fe6bd8136026b2707", size = 6994132, upload-time = "2025-07-24T18:53:34.506Z" }, + { url = "https://files.pythonhosted.org/packages/3b/0c/3a5fa47d2437a44ced74141795ac0251bbddeae74bf81df3447edd767d27/grpcio-1.74.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cce634b10aeab37010449124814b05a62fb5f18928ca878f1bf4750d1f0c815b", size = 6489616, upload-time = "2025-07-24T18:53:36.217Z" }, + { url = "https://files.pythonhosted.org/packages/ae/95/ab64703b436d99dc5217228babc76047d60e9ad14df129e307b5fec81fd0/grpcio-1.74.0-cp312-cp312-win32.whl", hash = "sha256:885912559974df35d92219e2dc98f51a16a48395f37b92865ad45186f294096c", size = 3807083, upload-time = "2025-07-24T18:53:37.911Z" }, + { url = "https://files.pythonhosted.org/packages/84/59/900aa2445891fc47a33f7d2f76e00ca5d6ae6584b20d19af9c06fa09bf9a/grpcio-1.74.0-cp312-cp312-win_amd64.whl", hash = "sha256:42f8fee287427b94be63d916c90399ed310ed10aadbf9e2e5538b3e497d269bc", size = 4490123, upload-time = "2025-07-24T18:53:39.528Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d8/1004a5f468715221450e66b051c839c2ce9a985aa3ee427422061fcbb6aa/grpcio-1.74.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2bc2d7d8d184e2362b53905cb1708c84cb16354771c04b490485fa07ce3a1d89", size = 5449488, upload-time = "2025-07-24T18:53:41.174Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/33731a03f63740d7743dced423846c831d8e6da808fcd02821a4416df7fa/grpcio-1.74.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c14e803037e572c177ba54a3e090d6eb12efd795d49327c5ee2b3bddb836bf01", size = 10974059, upload-time = "2025-07-24T18:53:43.066Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c6/3d2c14d87771a421205bdca991467cfe473ee4c6a1231c1ede5248c62ab8/grpcio-1.74.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f6ec94f0e50eb8fa1744a731088b966427575e40c2944a980049798b127a687e", size = 5945647, upload-time = "2025-07-24T18:53:45.269Z" }, + { url = "https://files.pythonhosted.org/packages/c5/83/5a354c8aaff58594eef7fffebae41a0f8995a6258bbc6809b800c33d4c13/grpcio-1.74.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:566b9395b90cc3d0d0c6404bc8572c7c18786ede549cdb540ae27b58afe0fb91", size = 6626101, upload-time = "2025-07-24T18:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ca/4fdc7bf59bf6994aa45cbd4ef1055cd65e2884de6113dbd49f75498ddb08/grpcio-1.74.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1ea6176d7dfd5b941ea01c2ec34de9531ba494d541fe2057c904e601879f249", size = 6182562, upload-time = "2025-07-24T18:53:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/fd/48/2869e5b2c1922583686f7ae674937986807c2f676d08be70d0a541316270/grpcio-1.74.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:64229c1e9cea079420527fa8ac45d80fc1e8d3f94deaa35643c381fa8d98f362", size = 6303425, upload-time = "2025-07-24T18:53:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/bac93147b9a164f759497bc6913e74af1cb632c733c7af62c0336782bd38/grpcio-1.74.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:0f87bddd6e27fc776aacf7ebfec367b6d49cad0455123951e4488ea99d9b9b8f", size = 6996533, upload-time = "2025-07-24T18:53:52.747Z" }, + { url = "https://files.pythonhosted.org/packages/84/35/9f6b2503c1fd86d068b46818bbd7329db26a87cdd8c01e0d1a9abea1104c/grpcio-1.74.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3b03d8f2a07f0fea8c8f74deb59f8352b770e3900d143b3d1475effcb08eec20", size = 6491489, upload-time = "2025-07-24T18:53:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/a04e99be2a82c4cbc4039eb3a76f6c3632932b9d5d295221389d10ac9ca7/grpcio-1.74.0-cp313-cp313-win32.whl", hash = "sha256:b6a73b2ba83e663b2480a90b82fdae6a7aa6427f62bf43b29912c0cfd1aa2bfa", size = 3805811, upload-time = "2025-07-24T18:53:56.798Z" }, + { url = "https://files.pythonhosted.org/packages/34/80/de3eb55eb581815342d097214bed4c59e806b05f1b3110df03b2280d6dfd/grpcio-1.74.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd3c71aeee838299c5887230b8a1822795325ddfea635edd82954c1eaa831e24", size = 4489214, upload-time = "2025-07-24T18:53:59.771Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1268,9 +1412,12 @@ name = "ipython" version = "9.5.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", - "python_full_version >= '3.11' and sys_platform == 'linux'", - "python_full_version >= '3.11' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and sys_platform == 'linux'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'linux'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, @@ -1664,6 +1811,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, ] +[[package]] +name = "msrest" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests-oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/77/8397c8fb8fc257d8ea0fa66f8068e073278c65f05acb17dcb22a02bfdc42/msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9", size = 175332, upload-time = "2022-06-13T22:41:25.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/cf/f2966a2638144491f8696c27320d5219f48a072715075d168b31d3237720/msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32", size = 85384, upload-time = "2022-06-13T22:41:22.42Z" }, +] + [[package]] name = "multidict" version = "6.6.4" @@ -1909,9 +2072,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "openai" -version = "1.103.0" +version = "1.106.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1923,9 +2095,9 @@ dependencies = [ { name = "tqdm", 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/cb/0b/4cacc14f976601edf35b74f7c5c2d6305f7402257cb13b9956b4eaabf94d/openai-1.103.0.tar.gz", hash = "sha256:f84f8741536f01adfdae1acfe31ec1874fc0985d33f53344f9edca773f150a36", size = 556049, upload-time = "2025-09-02T14:03:11.533Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b6/1aff7d6b8e9f0c3ac26bfbb57b9861a6711d5d60bd7dd5f7eebbf80509b7/openai-1.106.1.tar.gz", hash = "sha256:5f575967e3a05555825c43829cdcd50be6e49ab6a3e5262f0937a3f791f917f1", size = 561095, upload-time = "2025-09-04T18:17:15.303Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/c0/f5d440153d96c6be42e5d05c39adf33e0c324c9f035daf0e537a71fe2d11/openai-1.103.0-py3-none-any.whl", hash = "sha256:60a69224f0d210a720e7364947d3b712fe0036373f25dc1cb801fc25abb3f864", size = 926169, upload-time = "2025-09-02T14:03:09.666Z" }, + { url = "https://files.pythonhosted.org/packages/00/e1/47887212baa7bc0532880d33d5eafbdb46fcc4b53789b903282a74a85b5b/openai-1.106.1-py3-none-any.whl", hash = "sha256:bfdef37c949f80396c59f2c17e0eda35414979bc07ef3379596a93c9ed044f3a", size = 930768, upload-time = "2025-09-04T18:17:13.349Z" }, ] [[package]] @@ -1941,6 +2113,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl", hash = "sha256:02f20bcacf666e1333b6b1f04e647dc1d5111f86b8e510238fcc56d7762cda8c", size = 65564, upload-time = "2025-07-29T15:11:47.998Z" }, ] +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/da/7747e57eb341c59886052d733072bc878424bf20f1d8cf203d508bbece5b/opentelemetry_exporter_otlp_proto_common-1.36.0.tar.gz", hash = "sha256:6c496ccbcbe26b04653cecadd92f73659b814c6e3579af157d8716e5f9f25cbf", size = 20302, upload-time = "2025-07-29T15:12:07.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ed/22290dca7db78eb32e0101738366b5bbda00d0407f00feffb9bf8c3fdf87/opentelemetry_exporter_otlp_proto_common-1.36.0-py3-none-any.whl", hash = "sha256:0fc002a6ed63eac235ada9aa7056e5492e9a71728214a61745f6ad04b923f840", size = 18349, upload-time = "2025-07-29T15:11:51.327Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-common", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", 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/72/6f/6c1b0bdd0446e5532294d1d41bf11fbaea39c8a2423a4cdfe4fe6b708127/opentelemetry_exporter_otlp_proto_grpc-1.36.0.tar.gz", hash = "sha256:b281afbf7036b325b3588b5b6c8bb175069e3978d1bd24071f4a59d04c1e5bbf", size = 23822, upload-time = "2025-07-29T15:12:08.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/67/5f6bd188d66d0fd8e81e681bbf5822e53eb150034e2611dd2b935d3ab61a/opentelemetry_exporter_otlp_proto_grpc-1.36.0-py3-none-any.whl", hash = "sha256:734e841fc6a5d6f30e7be4d8053adb703c70ca80c562ae24e8083a28fadef211", size = 18828, upload-time = "2025-07-29T15:11:52.235Z" }, +] + [[package]] name = "opentelemetry-instrumentation" version = "0.57b0" @@ -1956,6 +2158,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/6f/f20cd1542959f43fb26a5bf9bb18cd81a1ea0700e8870c8f369bd07f5c65/opentelemetry_instrumentation-0.57b0-py3-none-any.whl", hash = "sha256:9109280f44882e07cec2850db28210b90600ae9110b42824d196de357cbddf7e", size = 32460, upload-time = "2025-07-29T15:41:40.883Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/10/7ba59b586eb099fa0155521b387d857de476687c670096597f618d889323/opentelemetry_instrumentation_asgi-0.57b0.tar.gz", hash = "sha256:a6f880b5d1838f65688fc992c65fbb1d3571f319d370990c32e759d3160e510b", size = 24654, upload-time = "2025-07-29T15:42:48.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/07/ab97dd7e8bc680b479203f7d3b2771b7a097468135a669a38da3208f96cb/opentelemetry_instrumentation_asgi-0.57b0-py3-none-any.whl", hash = "sha256:47debbde6af066a7e8e911f7193730d5e40d62effc1ac2e1119908347790a3ea", size = 16599, upload-time = "2025-07-29T15:41:48.332Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-dbapi" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/dc/5a17b2fb593901ba5257278073b28d0ed31497e56985990c26046e4da2d9/opentelemetry_instrumentation_dbapi-0.57b0.tar.gz", hash = "sha256:7ad9e39c91f6212f118435fd6fab842a1f78b2cbad1167f228c025bba2a8fc2d", size = 14176, upload-time = "2025-07-29T15:42:56.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/71/21a7e862dead70267b7c7bd5aa4e0b61fbc9fa9b4be57f4e183766abbad9/opentelemetry_instrumentation_dbapi-0.57b0-py3-none-any.whl", hash = "sha256:c1b110a5e86ec9b52b970460917523f47afa0c73f131e7f03c6a7c1921822dc4", size = 12466, upload-time = "2025-07-29T15:41:59.775Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-django" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-wsgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/88/d88268c37aabbd2bcc54f4f868394316fa6fdfd3b91e011d229617d862d3/opentelemetry_instrumentation_django-0.57b0.tar.gz", hash = "sha256:df4116d2ea2c6bbbbf8853b843deb74d66bd0d573ddd372ec84fd60adaf977c6", size = 25005, upload-time = "2025-07-29T15:42:56.88Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/f0/1d5022f2fe16d50b79d9f1f5b70bd08d0e59819e0f6b237cff82c3dbda0f/opentelemetry_instrumentation_django-0.57b0-py3-none-any.whl", hash = "sha256:3d702d79a9ec0c836ccf733becf34630c6afb3c86c25c330c5b7601debe1e7c5", size = 19597, upload-time = "2025-07-29T15:42:00.657Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-asgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/a8/7c22a33ff5986523a7f9afcb5f4d749533842c3cc77ef55b46727580edd0/opentelemetry_instrumentation_fastapi-0.57b0.tar.gz", hash = "sha256:73ac22f3c472a8f9cb21d1fbe5a4bf2797690c295fff4a1c040e9b1b1688a105", size = 20277, upload-time = "2025-07-29T15:42:58.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/df/f20fc21c88c7af5311bfefc15fc4e606bab5edb7c193aa8c73c354904c35/opentelemetry_instrumentation_fastapi-0.57b0-py3-none-any.whl", hash = "sha256:61e6402749ffe0bfec582e58155e0d81dd38723cd9bc4562bca1acca80334006", size = 12712, upload-time = "2025-07-29T15:42:03.332Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-flask" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-wsgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/98/8a8fa41f624069ac2912141b65bd528fd345d65e14a359c4d896fc3dc291/opentelemetry_instrumentation_flask-0.57b0.tar.gz", hash = "sha256:c5244a40b03664db966d844a32f43c900181431b77929be62a68d4907e86ed25", size = 19381, upload-time = "2025-07-29T15:42:59.38Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/3f/79b6c9a240221f5614a143eab6a0ecacdcb23b93cc35ff2b78234f68804f/opentelemetry_instrumentation_flask-0.57b0-py3-none-any.whl", hash = "sha256:5ecd614f194825725b61ee9ba8e37dcd4d3f9b5d40fef759df8650d6a91b1cb9", size = 14688, upload-time = "2025-07-29T15:42:04.162Z" }, +] + [[package]] name = "opentelemetry-instrumentation-openai" version = "0.46.2" @@ -1971,6 +2253,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/db/f6637a16f15763f12e727405a8ed0caaaca3f2d786b283fff0cd33d599d5/opentelemetry_instrumentation_openai-0.46.2-py3-none-any.whl", hash = "sha256:0880685a00752c31fdc4c6d9b959342156d62257515e9a8410431fcf7febe2a2", size = 35269, upload-time = "2025-08-29T18:07:30.132Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-psycopg2" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-dbapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/66/f2004cde131663810e62b47bb48b684660632876f120c6b1d400a04ccb06/opentelemetry_instrumentation_psycopg2-0.57b0.tar.gz", hash = "sha256:4e9d05d661c50985f0a5d7f090a7f399d453b467c9912c7611fcef693d15b038", size = 10722, upload-time = "2025-07-29T15:43:05.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/40/00f9c1334fb0c9d74c99d37c4a730cbe6dc941eea5fae6f9bc36e5a53d19/opentelemetry_instrumentation_psycopg2-0.57b0-py3-none-any.whl", hash = "sha256:94fdde02b7451c8e85d43b4b9dd13a34fee96ffd43324d1b3567f47d2903b99f", size = 10721, upload-time = "2025-07-29T15:42:15.698Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-requests" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/e1/01f5c28a60ffbc4c04946ad35bc8bf16382d333e41afaa042b31c35364b9/opentelemetry_instrumentation_requests-0.57b0.tar.gz", hash = "sha256:193bd3fd1f14737721876fb1952dffc7d43795586118df633a91ecd9057446ff", size = 15182, upload-time = "2025-07-29T15:43:11.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/7d/40144701fa22521e3b3fce23e2f0a5684a9385c90b119b70e7598b3cb607/opentelemetry_instrumentation_requests-0.57b0-py3-none-any.whl", hash = "sha256:66a576ac8080724ddc8a14c39d16bb5f430991bd504fdbea844c7a063f555971", size = 12966, upload-time = "2025-07-29T15:42:24.608Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-urllib" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/a5/9d400dd978ac5e81356fe8435ca264e140a7d4cf77a88db43791d62311d5/opentelemetry_instrumentation_urllib-0.57b0.tar.gz", hash = "sha256:657225ceae8bb52b67bd5c26dcb8a33f0efb041f1baea4c59dbd1adbc63a4162", size = 13929, upload-time = "2025-07-29T15:43:16.498Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/47/3c9535a68b9dd125eb6a25c086984e5cee7285e4f36bfa37eeb40e95d2b5/opentelemetry_instrumentation_urllib-0.57b0-py3-none-any.whl", hash = "sha256:bb3a01172109a6f56bfcc38ea83b9d4a61c4c2cac6b9a190e757063daadf545c", size = 12671, upload-time = "2025-07-29T15:42:34.561Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-urllib3" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/2d/c241e9716c94704dbddf64e2c7367b57642425455befdbc622936bec78e9/opentelemetry_instrumentation_urllib3-0.57b0.tar.gz", hash = "sha256:f49d8c3d1d81ae56304a08b14a7f564d250733ed75cd2210ccef815b5af2eea1", size = 15790, upload-time = "2025-07-29T15:43:17.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0e/a5467ab57d815caa58cbabb3a7f3906c3718c599221ac770482d13187306/opentelemetry_instrumentation_urllib3-0.57b0-py3-none-any.whl", hash = "sha256:337ecac6df3ff92026b51c64df7dd4a3fff52f2dc96036ea9371670243bf83c6", size = 13186, upload-time = "2025-07-29T15:42:35.775Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-wsgi" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/3f/d1ab49d68f2f6ebbe3c2fa5ff609ee5603a9cc68915203c454afb3a38d5b/opentelemetry_instrumentation_wsgi-0.57b0.tar.gz", hash = "sha256:d7e16b3b87930c30fc4c1bbc8b58c5dd6eefade493a3a5e7343bc24d572bc5b7", size = 18376, upload-time = "2025-07-29T15:43:17.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/0c/7760f9e14f4f8128e4880b4fd5f232ef4eb00cb29ee560c972dbf7801369/opentelemetry_instrumentation_wsgi-0.57b0-py3-none-any.whl", hash = "sha256:b9cf0c6e61489f7503fc17ef04d169bd214e7a825650ee492f5d2b4d73b17b54", size = 14450, upload-time = "2025-07-29T15:42:37.351Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/02/f6556142301d136e3b7e95ab8ea6a5d9dc28d879a99f3dd673b5f97dca06/opentelemetry_proto-1.36.0.tar.gz", hash = "sha256:0f10b3c72f74c91e0764a5ec88fd8f1c368ea5d9c64639fb455e2854ef87dd2f", size = 46152, upload-time = "2025-07-29T15:12:15.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/57/3361e06136225be8180e879199caea520f38026f8071366241ac458beb8d/opentelemetry_proto-1.36.0-py3-none-any.whl", hash = "sha256:151b3bf73a09f94afc658497cf77d45a565606f62ce0c17acb08cd9937ca206e", size = 72537, upload-time = "2025-07-29T15:12:02.243Z" }, +] + +[[package]] +name = "opentelemetry-resource-detector-azure" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e4/0d359d48d03d447225b30c3dd889d5d454e3b413763ff721f9b0e4ac2e59/opentelemetry_resource_detector_azure-0.1.5.tar.gz", hash = "sha256:e0ba658a87c69eebc806e75398cd0e9f68a8898ea62de99bc1b7083136403710", size = 11503, upload-time = "2024-05-16T21:54:58.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/ae/c26d8da88ba2e438e9653a408b0c2ad6f17267801250a8f3cc6405a93a72/opentelemetry_resource_detector_azure-0.1.5-py3-none-any.whl", hash = "sha256:4dcc5d54ab5c3b11226af39509bc98979a8b9e0f8a24c1b888783755d3bf00eb", size = 14252, upload-time = "2024-05-16T21:54:57.208Z" }, +] + [[package]] name = "opentelemetry-sdk" version = "1.36.0" @@ -2007,6 +2388,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/b5/cf25da2218910f0d6cdf7f876a06bed118c4969eacaf60a887cbaef44f44/opentelemetry_semantic_conventions_ai-0.4.13-py3-none-any.whl", hash = "sha256:883a30a6bb5deaec0d646912b5f9f6dcbb9f6f72557b73d0f2560bf25d13e2d5", size = 6080, upload-time = "2025-08-22T10:14:16.477Z" }, ] +[[package]] +name = "opentelemetry-util-http" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/1b/6229c45445e08e798fa825f5376f6d6a4211d29052a4088eed6d577fa653/opentelemetry_util_http-0.57b0.tar.gz", hash = "sha256:f7417595ead0eb42ed1863ec9b2f839fc740368cd7bbbfc1d0a47bc1ab0aba11", size = 9405, upload-time = "2025-07-29T15:43:19.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a6/b98d508d189b9c208f5978d0906141747d7e6df7c7cafec03657ed1ed559/opentelemetry_util_http-0.57b0-py3-none-any.whl", hash = "sha256:e54c0df5543951e471c3d694f85474977cd5765a3b7654398c83bab3d2ffb8e9", size = 7643, upload-time = "2025-07-29T15:42:41.744Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -2204,6 +2594,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] +[[package]] +name = "protobuf" +version = "6.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/df/fb4a8eeea482eca989b51cffd274aac2ee24e825f0bf3cbce5281fa1567b/protobuf-6.32.0.tar.gz", hash = "sha256:a81439049127067fc49ec1d36e25c6ee1d1a2b7be930675f919258d03c04e7d2", size = 440614, upload-time = "2025-08-14T21:21:25.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/18/df8c87da2e47f4f1dcc5153a81cd6bca4e429803f4069a299e236e4dd510/protobuf-6.32.0-cp310-abi3-win32.whl", hash = "sha256:84f9e3c1ff6fb0308dbacb0950d8aa90694b0d0ee68e75719cb044b7078fe741", size = 424409, upload-time = "2025-08-14T21:21:12.366Z" }, + { url = "https://files.pythonhosted.org/packages/e1/59/0a820b7310f8139bd8d5a9388e6a38e1786d179d6f33998448609296c229/protobuf-6.32.0-cp310-abi3-win_amd64.whl", hash = "sha256:a8bdbb2f009cfc22a36d031f22a625a38b615b5e19e558a7b756b3279723e68e", size = 435735, upload-time = "2025-08-14T21:21:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5b/0d421533c59c789e9c9894683efac582c06246bf24bb26b753b149bd88e4/protobuf-6.32.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d52691e5bee6c860fff9a1c86ad26a13afbeb4b168cd4445c922b7e2cf85aaf0", size = 426449, upload-time = "2025-08-14T21:21:16.687Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/607764ebe6c7a23dcee06e054fd1de3d5841b7648a90fd6def9a3bb58c5e/protobuf-6.32.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:501fe6372fd1c8ea2a30b4d9be8f87955a64d6be9c88a973996cef5ef6f0abf1", size = 322869, upload-time = "2025-08-14T21:21:18.282Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/2e730bd1c25392fc32e3268e02446f0d77cb51a2c3a8486b1798e34d5805/protobuf-6.32.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:75a2aab2bd1aeb1f5dc7c5f33bcb11d82ea8c055c9becbb41c26a8c43fd7092c", size = 322009, upload-time = "2025-08-14T21:21:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f2/80ffc4677aac1bc3519b26bc7f7f5de7fce0ee2f7e36e59e27d8beb32dd1/protobuf-6.32.0-py3-none-any.whl", hash = "sha256:ba377e5b67b908c8f3072a57b63e2c6a4cbd18aea4ed98d2584350dbf46f2783", size = 169287, upload-time = "2025-08-14T21:21:23.515Z" }, +] + [[package]] name = "psutil" version = "7.0.0" @@ -2406,20 +2810,20 @@ crypto = [ [[package]] name = "pyright" -version = "1.1.404" +version = "1.1.405" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv", 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/e2/6e/026be64c43af681d5632722acd100b06d3d39f383ec382ff50a71a6d5bce/pyright-1.1.404.tar.gz", hash = "sha256:455e881a558ca6be9ecca0b30ce08aa78343ecc031d37a198ffa9a7a1abeb63e", size = 4065679, upload-time = "2025-08-20T18:46:14.029Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/6c/ba4bbee22e76af700ea593a1d8701e3225080956753bee9750dcc25e2649/pyright-1.1.405.tar.gz", hash = "sha256:5c2a30e1037af27eb463a1cc0b9f6d65fec48478ccf092c1ac28385a15c55763", size = 4068319, upload-time = "2025-09-04T03:37:06.776Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/30/89aa7f7d7a875bbb9a577d4b1dc5a3e404e3d2ae2657354808e905e358e0/pyright-1.1.404-py3-none-any.whl", hash = "sha256:c7b7ff1fdb7219c643079e4c3e7d4125f0dafcc19d253b47e898d130ea426419", size = 5902951, upload-time = "2025-08-20T18:46:12.096Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1a/524f832e1ff1962a22a1accc775ca7b143ba2e9f5924bb6749dce566784a/pyright-1.1.405-py3-none-any.whl", hash = "sha256:a2cb13700b5508ce8e5d4546034cb7ea4aedb60215c6c33f56cec7f53996035a", size = 5905038, upload-time = "2025-09-04T03:37:04.913Z" }, ] [[package]] name = "pytest" -version = "8.4.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2430,9 +2834,9 @@ dependencies = [ { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] @@ -2701,6 +3105,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + [[package]] name = "rich" version = "14.1.0" @@ -2860,28 +3277,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.12.11" +version = "0.12.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/55/16ab6a7d88d93001e1ae4c34cbdcfb376652d761799459ff27c1dc20f6fa/ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d", size = 5347103, upload-time = "2025-08-28T13:59:08.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/f0/e0965dd709b8cabe6356811c0ee8c096806bb57d20b5019eb4e48a117410/ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6", size = 5359915, upload-time = "2025-09-04T16:50:18.273Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/a2/3b3573e474de39a7a475f3fbaf36a25600bfeb238e1a90392799163b64a0/ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065", size = 11979885, upload-time = "2025-08-28T13:58:26.654Z" }, - { url = "https://files.pythonhosted.org/packages/76/e4/235ad6d1785a2012d3ded2350fd9bc5c5af8c6f56820e696b0118dfe7d24/ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93", size = 12742364, upload-time = "2025-08-28T13:58:30.256Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0d/15b72c5fe6b1e402a543aa9d8960e0a7e19dfb079f5b0b424db48b7febab/ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd", size = 11920111, upload-time = "2025-08-28T13:58:33.677Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c0/f66339d7893798ad3e17fa5a1e587d6fd9806f7c1c062b63f8b09dda6702/ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee", size = 12160060, upload-time = "2025-08-28T13:58:35.74Z" }, - { url = "https://files.pythonhosted.org/packages/03/69/9870368326db26f20c946205fb2d0008988aea552dbaec35fbacbb46efaa/ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0", size = 11799848, upload-time = "2025-08-28T13:58:38.051Z" }, - { url = "https://files.pythonhosted.org/packages/25/8c/dd2c7f990e9b3a8a55eee09d4e675027d31727ce33cdb29eab32d025bdc9/ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644", size = 13536288, upload-time = "2025-08-28T13:58:40.046Z" }, - { url = "https://files.pythonhosted.org/packages/7a/30/d5496fa09aba59b5e01ea76775a4c8897b13055884f56f1c35a4194c2297/ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211", size = 14490633, upload-time = "2025-08-28T13:58:42.285Z" }, - { url = "https://files.pythonhosted.org/packages/9b/2f/81f998180ad53445d403c386549d6946d0748e536d58fce5b5e173511183/ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793", size = 13888430, upload-time = "2025-08-28T13:58:44.641Z" }, - { url = "https://files.pythonhosted.org/packages/87/71/23a0d1d5892a377478c61dbbcffe82a3476b050f38b5162171942a029ef3/ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee", size = 12913133, upload-time = "2025-08-28T13:58:47.039Z" }, - { url = "https://files.pythonhosted.org/packages/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8", size = 13169082, upload-time = "2025-08-28T13:58:49.157Z" }, - { url = "https://files.pythonhosted.org/packages/05/b5/68b3ff96160d8b49e8dd10785ff3186be18fd650d356036a3770386e6c7f/ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f", size = 13139490, upload-time = "2025-08-28T13:58:51.593Z" }, - { url = "https://files.pythonhosted.org/packages/59/b9/050a3278ecd558f74f7ee016fbdf10591d50119df8d5f5da45a22c6afafc/ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000", size = 11958928, upload-time = "2025-08-28T13:58:53.943Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bc/93be37347db854806904a43b0493af8d6873472dfb4b4b8cbb27786eb651/ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2", size = 11764513, upload-time = "2025-08-28T13:58:55.976Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a1/1471751e2015a81fd8e166cd311456c11df74c7e8769d4aabfbc7584c7ac/ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39", size = 12745154, upload-time = "2025-08-28T13:58:58.16Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/2542b14890d0f4872dd81b7b2a6aed3ac1786fae1ce9b17e11e6df9e31e3/ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9", size = 13227653, upload-time = "2025-08-28T13:59:00.276Z" }, - { url = "https://files.pythonhosted.org/packages/22/16/2fbfc61047dbfd009c58a28369a693a1484ad15441723be1cd7fe69bb679/ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3", size = 11944270, upload-time = "2025-08-28T13:59:02.347Z" }, - { url = "https://files.pythonhosted.org/packages/08/a5/34276984705bfe069cd383101c45077ee029c3fe3b28225bf67aa35f0647/ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd", size = 13046600, upload-time = "2025-08-28T13:59:04.751Z" }, - { url = "https://files.pythonhosted.org/packages/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290, upload-time = "2025-08-28T13:59:06.933Z" }, + { url = "https://files.pythonhosted.org/packages/09/79/8d3d687224d88367b51c7974cec1040c4b015772bfbeffac95face14c04a/ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc", size = 12116602, upload-time = "2025-09-04T16:49:18.892Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c3/6e599657fe192462f94861a09aae935b869aea8a1da07f47d6eae471397c/ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727", size = 12868393, upload-time = "2025-09-04T16:49:23.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/9e3e40d399abc95336b1843f52fc0daaceb672d0e3c9290a28ff1a96f79d/ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb", size = 12036967, upload-time = "2025-09-04T16:49:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/e9/03/6816b2ed08836be272e87107d905f0908be5b4a40c14bfc91043e76631b8/ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577", size = 12276038, upload-time = "2025-09-04T16:49:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d5/707b92a61310edf358a389477eabd8af68f375c0ef858194be97ca5b6069/ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e", size = 11901110, upload-time = "2025-09-04T16:49:32.07Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3d/f8b1038f4b9822e26ec3d5b49cf2bc313e3c1564cceb4c1a42820bf74853/ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e", size = 13668352, upload-time = "2025-09-04T16:49:35.148Z" }, + { url = "https://files.pythonhosted.org/packages/98/0e/91421368ae6c4f3765dd41a150f760c5f725516028a6be30e58255e3c668/ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8", size = 14638365, upload-time = "2025-09-04T16:49:38.892Z" }, + { url = "https://files.pythonhosted.org/packages/74/5d/88f3f06a142f58ecc8ecb0c2fe0b82343e2a2b04dcd098809f717cf74b6c/ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5", size = 14060812, upload-time = "2025-09-04T16:49:42.732Z" }, + { url = "https://files.pythonhosted.org/packages/13/fc/8962e7ddd2e81863d5c92400820f650b86f97ff919c59836fbc4c1a6d84c/ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92", size = 13050208, upload-time = "2025-09-04T16:49:46.434Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/8deb52d48a9a624fd37390555d9589e719eac568c020b27e96eed671f25f/ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45", size = 13311444, upload-time = "2025-09-04T16:49:49.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/de5a29af7eb8f341f8140867ffb93f82e4fde7256dadee79016ac87c2716/ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5", size = 13279474, upload-time = "2025-09-04T16:49:53.465Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/d9577fdeaf791737ada1b4f5c6b59c21c3326f3f683229096cccd7674e0c/ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4", size = 12070204, upload-time = "2025-09-04T16:49:56.882Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/a910078284b47fad54506dc0af13839c418ff704e341c176f64e1127e461/ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23", size = 11880347, upload-time = "2025-09-04T16:49:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/30185fcb0e89f05e7ea82e5817b47798f7fa7179863f9d9ba6fd4fe1b098/ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489", size = 12891844, upload-time = "2025-09-04T16:50:02.591Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/28a8dacce4855e6703dcb8cdf6c1705d0b23dd01d60150786cd55aa93b16/ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee", size = 13360687, upload-time = "2025-09-04T16:50:05.8Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fa/05b6428a008e60f79546c943e54068316f32ec8ab5c4f73e4563934fbdc7/ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1", size = 12052870, upload-time = "2025-09-04T16:50:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/85/60/d1e335417804df452589271818749d061b22772b87efda88354cf35cdb7a/ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d", size = 13178016, upload-time = "2025-09-04T16:50:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, ] [[package]] @@ -2958,9 +3375,12 @@ name = "sphinx" version = "8.2.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", - "python_full_version >= '3.11' and sys_platform == 'linux'", - "python_full_version >= '3.11' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and sys_platform == 'linux'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'linux'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", ] dependencies = [ { name = "alabaster", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, @@ -3013,9 +3433,12 @@ name = "sphinx-autobuild" version = "2025.8.25" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", - "python_full_version >= '3.11' and sys_platform == 'linux'", - "python_full_version >= '3.11' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and sys_platform == 'linux'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'linux'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'", ] dependencies = [ { name = "colorama", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, @@ -3345,28 +3768,28 @@ wheels = [ [[package]] name = "uv" -version = "0.8.14" +version = "0.8.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e2/b0/c3bc06ba5f6b72ba3ad278e854292d81b7aaaea2b6988e40fdb892f813f8/uv-0.8.14.tar.gz", hash = "sha256:7c68e0cde3d048500c073696881c07c2bd97503fc77d7091e1454d3fd58febb4", size = 3543853, upload-time = "2025-08-28T21:55:59.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/7c/ab905b0425f88842f3d8e5da50491524f45a231b7a3dc9c988608162adb2/uv-0.8.15.tar.gz", hash = "sha256:8ea57b78be9f0911a2a50b6814d15aec7d1f8aa6517059dc8250b1414156f93a", size = 3602914, upload-time = "2025-09-03T14:32:15.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/a3/bf0a80a7770f5c11a735345073fdf085a031ecd0525ae229ceb3ed7496f5/uv-0.8.14-py3-none-linux_armv6l.whl", hash = "sha256:bae6621a72e6643f140c4e62f10d3a52d210ccdec48bf4f733e6a25d5739e533", size = 18810682, upload-time = "2025-08-28T21:55:07.027Z" }, - { url = "https://files.pythonhosted.org/packages/61/de/e8d3c1669edb70ae165ad6c06598ff237ddbc1dc743cc590a2c30c245b93/uv-0.8.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2334945ef3dba395067164c7e25b0c1420d8fdab9637d33cb753b5dbe0499b2c", size = 18939300, upload-time = "2025-08-28T21:55:11.244Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/9e4c3382f79cef69229f4f301ce1b391121f5a9d1015dd82487e08f0d718/uv-0.8.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9a65096847d3341713be92e98cb35d5315d172690032405e8ae4e1b0c366a19a", size = 17555624, upload-time = "2025-08-28T21:55:14.107Z" }, - { url = "https://files.pythonhosted.org/packages/03/6d/5200cba528844e33586fadae78c06c054774e7702063356795f6cc124331/uv-0.8.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:f7a5d72e4fefae57f675cf0ac0adb9e68fb638f3f95be142b7f072fc6fddfe3e", size = 18151749, upload-time = "2025-08-28T21:55:16.904Z" }, - { url = "https://files.pythonhosted.org/packages/5a/b6/6f9407a792f0ca566b61276cadbffa032cff4039847ac77c47959151f753/uv-0.8.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:935b602d40f0c6a41337de81a02850d6892b0c8c6b5d98543fa229d5bb247364", size = 18472626, upload-time = "2025-08-28T21:55:19.994Z" }, - { url = "https://files.pythonhosted.org/packages/14/a2/2eadfccb1d6aa3672c947071b18c50cee41bdb9c9dba6d8af011a5c44e50/uv-0.8.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34286de8d1244f06124c5bd7b4bfb5ef5791c147e0aa4473c7856c02fedc58ff", size = 19292728, upload-time = "2025-08-28T21:55:22.441Z" }, - { url = "https://files.pythonhosted.org/packages/b6/db/96071cddd37e4bfc9bd10c4daab0942c3d610da92f32c74de07621990455/uv-0.8.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d26ea49a595992bc58d31bb6a10660a8015d902b6845c8ceed1e011866013593", size = 20577332, upload-time = "2025-08-28T21:55:25.774Z" }, - { url = "https://files.pythonhosted.org/packages/c9/4c/8e0da19b4bd5612bd782a82a1869c71e8ea059b59c547230146d36583a39/uv-0.8.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2aa721841812e9a74cad883dbd0f6cf908309cc40a86ab33d3576a8b369595a9", size = 20317704, upload-time = "2025-08-28T21:55:28.537Z" }, - { url = "https://files.pythonhosted.org/packages/1c/f2/4ad6abe850e31663d3971eb4af4a3b6ef216870f4f2115ae65e72917ea02/uv-0.8.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5088fa0ceff698a3fb2464f5cd7ebb4af59aa85db4ba83150d4c3af027251228", size = 19615504, upload-time = "2025-08-28T21:55:31.695Z" }, - { url = "https://files.pythonhosted.org/packages/ed/6c/b86f5f2f5aeebb0028034ea180399af23c8cbc42748bba0672c9cabdde38/uv-0.8.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3853202f4eb0bedbe31b0b62b1323521e97306f44f8f4b6ed4bb13b636797873", size = 19605107, upload-time = "2025-08-28T21:55:34.33Z" }, - { url = "https://files.pythonhosted.org/packages/b4/04/7b019c63d26d296bf6dfd8ad9b86e51f84b2ec7f37d68f8b93138a3fa404/uv-0.8.14-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e45047a89592a5b38c88caa6da5d1b70a05c9762ff1c5100f9700f85f533dc99", size = 18412515, upload-time = "2025-08-28T21:55:37.185Z" }, - { url = "https://files.pythonhosted.org/packages/59/b8/c277b6ff1e4fc6d2c4f000ebccef9c2879603875ab092390f7073b911bdf/uv-0.8.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72971573f21e617267b3737750cdb8a9ae99862b06d23df7fde60fc9f8ef78d6", size = 19290057, upload-time = "2025-08-28T21:55:39.769Z" }, - { url = "https://files.pythonhosted.org/packages/ed/09/59f84ea996bc3bf52c88bc7ba2d988bc5edfd7d0a9aee7cc0500f77d83ce/uv-0.8.14-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:ab22d9712f6b06b04359cfaf625722a81fcd0f2335868738dbee26a79a93bd99", size = 18433918, upload-time = "2025-08-28T21:55:42.262Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2c/8a76455ea1f578fab8a88457c4d50c28928860335d3420956b75661f5e7b/uv-0.8.14-py3-none-musllinux_1_1_i686.whl", hash = "sha256:b5003c30c44065b70e03f083d73af45c094f1f96d9c394acafd8f547c2aee4d0", size = 18800856, upload-time = "2025-08-28T21:55:44.697Z" }, - { url = "https://files.pythonhosted.org/packages/f7/87/16699c592d816325554702d771024fbe5ec39127bfbc06d5cb54843673bb/uv-0.8.14-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:dacfad1193c7facd3a414cc2f3468b4a79a07c565c776a3136f97527a628b960", size = 19704752, upload-time = "2025-08-28T21:55:47.375Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e9/0cdeed22e6c540db493ea364040b17af09fabaa7a56c8ff02b9152819442/uv-0.8.14-py3-none-win32.whl", hash = "sha256:0a4abb2a327e3709ef02765dc392ee10e204275bdb107b492977f88633a1e6b0", size = 18630132, upload-time = "2025-08-28T21:55:51.988Z" }, - { url = "https://files.pythonhosted.org/packages/45/5e/9bf7004bd53e9279265d73a131fe2a6c7d74c1125c53e805b5e9f4047f37/uv-0.8.14-py3-none-win_amd64.whl", hash = "sha256:5091d588753bbbd1f120f13311ede2ae113d7ec2760e149fc502a237f2516075", size = 20672637, upload-time = "2025-08-28T21:55:55.341Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7f/41074c81faa36a34d44524997c345a857bd82d7f73ea60e24dca606306ec/uv-0.8.14-py3-none-win_arm64.whl", hash = "sha256:7c424fd4561f4528d8b52fc8c16991d0ad0000d3ad12c82e01e722f314b2669d", size = 19171656, upload-time = "2025-08-28T21:55:57.799Z" }, + { url = "https://files.pythonhosted.org/packages/67/1d/794352a01b40f2b0a0abe02f4f020219b3f59ee6ed900561be3b2b47a82b/uv-0.8.15-py3-none-linux_armv6l.whl", hash = "sha256:f02e6b8be08b840f86b8d5997b658b657acdda95bc216ecf62fce6c71414bdc7", size = 20136396, upload-time = "2025-09-03T14:31:30.404Z" }, + { url = "https://files.pythonhosted.org/packages/8f/89/528f01cff01eb8d10dd396f437656266443e399dda2fe4787b2cf6983698/uv-0.8.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b0461bb1ad616c8bcb59c9b39ae9363245ca33815ebb1d11130385236eca21b9", size = 19297422, upload-time = "2025-09-03T14:31:34.412Z" }, + { url = "https://files.pythonhosted.org/packages/94/03/532af32a64d162894a1daebb7bc5028ba00225ea720cf0f287e934dc2bd5/uv-0.8.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:069eed78b79d1e88bced23e3d4303348edb0a0209e7cae0f20024c42430bf50f", size = 17882409, upload-time = "2025-09-03T14:31:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/25/21/57df6d53fbadfa947d9d65a0926e5d8540199f49aa958d23be2707262a80/uv-0.8.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:333a93bb6af64f3b95ee99e82b4ea227e2af6362c45f91c89a24e2bfefb628f9", size = 19557216, upload-time = "2025-09-03T14:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/68/22/c3784749e1c78119e5375ec34c6ea29e944192a601f17c746339611db237/uv-0.8.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7d5b19ac2bdda3d1456b5d6013af50b443ffb0e40c66d42874f71190a5364711", size = 19781097, upload-time = "2025-09-03T14:31:42.314Z" }, + { url = "https://files.pythonhosted.org/packages/00/28/0597599fb35408dd73e0a7d25108dca1fa6ce8f8d570c8f24151b0016eef/uv-0.8.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3330bb4f206a6180679a75a8b2e77ff0f933fcb06c028b6f4da877b10a5e4f95", size = 20741549, upload-time = "2025-09-03T14:31:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/4f/61/98fa07981722660f5a3c28b987df99c2486f63d01b1256e6cca05a43bdce/uv-0.8.15-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:de9896ad4fa724ab317a8048f4891b9b23df1403b3724e96606f3be2dbbbf009", size = 22193727, upload-time = "2025-09-03T14:31:46.915Z" }, + { url = "https://files.pythonhosted.org/packages/fa/65/523188e11a759144b00f0fe48943f6d00706fcd9b5f561a54a07b9fd4541/uv-0.8.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:226360003e71084e0a73cbec72170e88634b045e95529654d067ea3741bba242", size = 21817550, upload-time = "2025-09-03T14:31:49.548Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/7898acf3d9ed2d3a2986cccc8209c14d3e9ac72dfaa616e49d329423b1d3/uv-0.8.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9488260536b35b94a79962fea76837f279c0cd0ae5021c761e66b311f47ffa70", size = 21024011, upload-time = "2025-09-03T14:31:51.789Z" }, + { url = "https://files.pythonhosted.org/packages/13/fc/e0da45ee179367dcc1e1040ad00ed8a99b78355d43024b0b5fc2edf5c389/uv-0.8.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07765f99fd5fd3b257d7e210e8d0844c0a8fd111612e31fcca66a85656cc728e", size = 21009338, upload-time = "2025-09-03T14:31:54.104Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/180904fa7ed49081b27f00e86f7220ca62cc098d7ef6459f0c69a8ae8f74/uv-0.8.15-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c4868e6a4e1a8c777a5ba3cff452c405837318fb0b272ff203bfda0e1b8fc54d", size = 19799578, upload-time = "2025-09-03T14:31:56.47Z" }, + { url = "https://files.pythonhosted.org/packages/b6/09/fed823212e695b6765bdb8462850abffbe685cd965c4de905efed5e2e5c9/uv-0.8.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3ec78a54a8eb0bbb9a9c653982390af84673657c8a48a0be6cdcb81d7d3e95c3", size = 20845428, upload-time = "2025-09-03T14:31:59.475Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f3/9c4211897c00f79b7973a10800166e0580eaad20fe27f7c06adb7b248ac7/uv-0.8.15-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:a022a752d20da80d2a49fc0721522a81e3a32efe539152d756d84ebdba29dbc3", size = 19728113, upload-time = "2025-09-03T14:32:01.686Z" }, + { url = "https://files.pythonhosted.org/packages/3b/43/4ec6047150e2fba494d80d36b881a1a973835afa497ae9ccdf51828cae4f/uv-0.8.15-py3-none-musllinux_1_1_i686.whl", hash = "sha256:3780d2f3951d83e55812fdeb7eee233787b70c774497dbfc55b0fdf6063aa345", size = 20169115, upload-time = "2025-09-03T14:32:03.995Z" }, + { url = "https://files.pythonhosted.org/packages/ea/98/b4220bf462fb225c4a2d74ef4f105020238472b4b0da94ebc17a310d7b4e/uv-0.8.15-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:56f2451c9193ee1754ce1d8390ded68e9cb8dee0aaf7e2f38a9bd04d99be1be7", size = 21129804, upload-time = "2025-09-03T14:32:06.204Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b8/40ce3d385254ac87a664a5d9a4664fac697e2734352f404382b81d03235b/uv-0.8.15-py3-none-win32.whl", hash = "sha256:89c7c10089e07d944c72d388fd88666c650dec2f8c79ca541e365f32843882c6", size = 19077103, upload-time = "2025-09-03T14:32:08.628Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/081a0af395c0e307c0c930e80161a2aa551c25064cfb636d060574566fa4/uv-0.8.15-py3-none-win_amd64.whl", hash = "sha256:6aa824ab933dfafe11efe32e6541c6bcd65ecaa927e8e834ea6b14d3821020f6", size = 21179816, upload-time = "2025-09-03T14:32:11.42Z" }, + { url = "https://files.pythonhosted.org/packages/30/47/d8f50264a8c8ebbb9a44a8fed08b6e873d943adf299d944fe3a776ff5fbf/uv-0.8.15-py3-none-win_arm64.whl", hash = "sha256:a395fa1fc8948eacdd18e4592ed489fad13558b13fea6b3544cb16e5006c5b02", size = 19448833, upload-time = "2025-09-03T14:32:13.639Z" }, ] [[package]]