From 863aab7072051dfd89a5dafa27cdc4aefddf1998 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Tue, 10 Feb 2026 17:29:39 +0000 Subject: [PATCH 01/20] feat(realtime): add realtime voice agents with OpenAI, Azure OpenAI, and Voice Live clients - Add BaseRealtimeClient protocol with connect, disconnect, send_audio, send_text, send_tool_result, update_session, and events methods - Add AzureOpenAIRealtimeClient using the OpenAI SDK beta realtime API - Add OpenAIRealtimeClient using the OpenAI SDK GA realtime API - Add AzureVoiceLiveClient using the Azure Voice Live SDK - Add RealtimeAgent for high-level voice agent orchestration - Add RealtimeSessionConfig, RealtimeEvent, and related types - Add update_session() for changing session config without reconnecting - Add public tool_to_schema() and execute_tool() helper functions - Add multi-agent sample demonstrating agent transfers via single connection - Add single-agent and bidirectional audio samples - Add comprehensive test coverage for all clients and agent --- python/packages/azure-ai-voice-live/LICENSE | 21 + python/packages/azure-ai-voice-live/README.md | 47 ++ .../__init__.py | 17 + .../_client.py | 410 ++++++++++++ .../_settings.py | 82 +++ .../agent_framework_azure_voice_live/py.typed | 0 .../azure-ai-voice-live/pyproject.toml | 86 +++ .../azure-ai-voice-live/tests/__init__.py | 0 .../azure-ai-voice-live/tests/test_client.py | 627 ++++++++++++++++++ .../tests/test_settings.py | 53 ++ .../packages/core/agent_framework/__init__.py | 5 + .../core/agent_framework/_realtime_agent.py | 205 ++++++ .../core/agent_framework/_realtime_client.py | 215 ++++++ .../core/agent_framework/_realtime_types.py | 46 ++ .../core/agent_framework/azure/__init__.py | 3 + .../agent_framework/azure/_realtime_client.py | 386 +++++++++++ .../core/agent_framework/azure/_shared.py | 7 + .../core/agent_framework/openai/__init__.py | 1 + .../openai/_realtime_client.py | 350 ++++++++++ .../test_azure_openai_settings_realtime.py | 23 + .../tests/azure/test_azure_realtime_client.py | 233 +++++++ .../core/tests/core/test_realtime_agent.py | 529 +++++++++++++++ .../core/tests/core/test_realtime_client.py | 198 ++++++ .../core/tests/core/test_realtime_types.py | 85 +++ .../openai/test_openai_realtime_client.py | 249 +++++++ python/pyproject.toml | 1 + .../getting_started/realtime/README.md | 137 ++++ .../getting_started/realtime/__init__.py | 1 + .../getting_started/realtime/audio_utils.py | 151 +++++ .../realtime/realtime_fastapi_websocket.py | 366 ++++++++++ .../realtime/realtime_with_microphone.py | 195 ++++++ .../realtime/realtime_with_multiple_agents.py | 476 +++++++++++++ .../realtime/realtime_with_tools.py | 266 ++++++++ .../realtime/websocket_audio_client.py | 252 +++++++ python/uv.lock | 44 ++ 35 files changed, 5767 insertions(+) create mode 100644 python/packages/azure-ai-voice-live/LICENSE create mode 100644 python/packages/azure-ai-voice-live/README.md create mode 100644 python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/__init__.py create mode 100644 python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_client.py create mode 100644 python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_settings.py create mode 100644 python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/py.typed create mode 100644 python/packages/azure-ai-voice-live/pyproject.toml create mode 100644 python/packages/azure-ai-voice-live/tests/__init__.py create mode 100644 python/packages/azure-ai-voice-live/tests/test_client.py create mode 100644 python/packages/azure-ai-voice-live/tests/test_settings.py create mode 100644 python/packages/core/agent_framework/_realtime_agent.py create mode 100644 python/packages/core/agent_framework/_realtime_client.py create mode 100644 python/packages/core/agent_framework/_realtime_types.py create mode 100644 python/packages/core/agent_framework/azure/_realtime_client.py create mode 100644 python/packages/core/agent_framework/openai/_realtime_client.py create mode 100644 python/packages/core/tests/azure/test_azure_openai_settings_realtime.py create mode 100644 python/packages/core/tests/azure/test_azure_realtime_client.py create mode 100644 python/packages/core/tests/core/test_realtime_agent.py create mode 100644 python/packages/core/tests/core/test_realtime_client.py create mode 100644 python/packages/core/tests/core/test_realtime_types.py create mode 100644 python/packages/core/tests/openai/test_openai_realtime_client.py create mode 100644 python/samples/getting_started/realtime/README.md create mode 100644 python/samples/getting_started/realtime/__init__.py create mode 100644 python/samples/getting_started/realtime/audio_utils.py create mode 100644 python/samples/getting_started/realtime/realtime_fastapi_websocket.py create mode 100644 python/samples/getting_started/realtime/realtime_with_microphone.py create mode 100644 python/samples/getting_started/realtime/realtime_with_multiple_agents.py create mode 100644 python/samples/getting_started/realtime/realtime_with_tools.py create mode 100644 python/samples/getting_started/realtime/websocket_audio_client.py diff --git a/python/packages/azure-ai-voice-live/LICENSE b/python/packages/azure-ai-voice-live/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/azure-ai-voice-live/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/azure-ai-voice-live/README.md b/python/packages/azure-ai-voice-live/README.md new file mode 100644 index 0000000000..0bca939e85 --- /dev/null +++ b/python/packages/azure-ai-voice-live/README.md @@ -0,0 +1,47 @@ +# Get Started with Microsoft Agent Framework Azure Voice Live + +Please install this package via pip: + +```bash +pip install agent-framework-azure-voice-live --pre +``` + +## Azure Voice Live Integration + +The Azure Voice Live integration provides real-time voice conversation capabilities using Azure's Voice Live SDK, supporting bidirectional audio streaming with Azure Speech Services and generative AI models. + +### Features + +- **Real-time Audio Streaming**: Bidirectional audio communication with AI models +- **Voice Selection**: Support for OpenAI voices (alloy, echo, shimmer, etc.) and Azure Neural voices +- **Tool Calling**: Function calling support during voice conversations +- **Environment Variables**: Easy configuration via AZURE_VOICELIVE_* environment variables +- **Authentication**: Support for both API keys and Azure identity credentials + +### Basic Usage Example + +```python +from agent_framework import RealtimeAgent +from agent_framework_azure_voice_live import AzureVoiceLiveClient + +# Create client +client = AzureVoiceLiveClient( + endpoint="https://myresource.services.ai.azure.com", + model="gpt-4o-realtime-preview", + api_key="your-api-key", +) + +# Create agent +agent = client.as_agent( + name="voice-assistant", + instructions="You are a helpful voice assistant.", + voice="alloy", +) + +# Run voice conversation +async for event in agent.run(audio_input=microphone_stream()): + if event.type == "audio": + play_audio(event.data["audio"]) +``` + +For more examples, see the [Azure Voice Live examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/azure_ai/). diff --git a/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/__init__.py b/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/__init__.py new file mode 100644 index 0000000000..80def1e41d --- /dev/null +++ b/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib.metadata + +from ._client import AzureVoiceLiveClient +from ._settings import AzureVoiceLiveSettings + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = [ + "AzureVoiceLiveClient", + "AzureVoiceLiveSettings", + "__version__", +] diff --git a/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_client.py b/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_client.py new file mode 100644 index 0000000000..77172bda83 --- /dev/null +++ b/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_client.py @@ -0,0 +1,410 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Azure Voice Live realtime client using the SDK.""" + +from __future__ import annotations + +import contextlib +import logging +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any, ClassVar + +from agent_framework._realtime_client import BaseRealtimeClient +from agent_framework._realtime_types import RealtimeEvent, RealtimeSessionConfig +from agent_framework.exceptions import ServiceInitializationError +from azure.ai.voicelive.aio import connect as vl_connect +from azure.ai.voicelive.models import ( + FunctionCallOutputItem, + InputTextContentPart, + ServerEventType, + UserMessageItem, +) +from azure.core.credentials import AzureKeyCredential +from pydantic import ValidationError + +from ._settings import AzureVoiceLiveSettings + +if TYPE_CHECKING: + from azure.core.credentials import TokenCredential + +logger = logging.getLogger(__name__) + +__all__ = ["AzureVoiceLiveClient"] + +# OpenAI voice names supported by Voice Live +_OPENAI_VOICES = { + "alloy", + "ash", + "ballad", + "coral", + "echo", + "sage", + "shimmer", + "verse", + "marin", + "cedar", +} + + +class AzureVoiceLiveClient(BaseRealtimeClient): + """Azure Voice Live API client using the SDK's native WebSocket support. + + Connects to Azure Voice Live for bidirectional audio streaming with + Azure Speech Services and generative AI models. + + Example: + ```python + # With API key + client = AzureVoiceLiveClient( + endpoint="https://myresource.services.ai.azure.com", + model="gpt-4o-realtime-preview", + api_key="your-api-key", + ) + + # With Azure credential + from azure.identity.aio import DefaultAzureCredential + + client = AzureVoiceLiveClient( + endpoint="https://myresource.services.ai.azure.com", + model="gpt-4o-realtime-preview", + credential=DefaultAzureCredential(), + ) + + await client.connect( + RealtimeSessionConfig( + instructions="You are helpful.", + voice="alloy", + ) + ) + + async for event in client.events(): + if event.type == "audio": + play_audio(event.data["audio"]) + ``` + """ + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.voice_live" + + def __init__( + self, + *, + endpoint: str | None = None, + api_key: str | None = None, + credential: TokenCredential | None = None, + model: str | None = None, + api_version: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize Azure Voice Live client. + + Keyword Args: + endpoint: Azure Voice Live endpoint URL (https:// or wss://). + Can also be set via AZURE_VOICELIVE_ENDPOINT env var. + api_key: API key for authentication (mutually exclusive with credential). + Can also be set via AZURE_VOICELIVE_API_KEY env var. + credential: Azure TokenCredential for Entra ID authentication. + model: Model deployment name (e.g., gpt-4o-realtime-preview). + Can also be set via AZURE_VOICELIVE_MODEL env var. + api_version: API version string (default: 2025-10-01). + Can also be set via AZURE_VOICELIVE_API_VERSION env var. + env_file_path: Path to .env file for settings fallback. + env_file_encoding: Encoding of .env file (default: utf-8). + **kwargs: Additional keyword arguments passed to BaseRealtimeClient. + """ + try: + settings = AzureVoiceLiveSettings( + endpoint=endpoint, + api_key=api_key, # type: ignore[arg-type] + model=model, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + except ValidationError as exc: + raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc + + if not settings.endpoint: + raise ServiceInitializationError( + "Azure Voice Live endpoint is required. Set via 'endpoint' parameter " + "or 'AZURE_VOICELIVE_ENDPOINT' environment variable." + ) + + if not settings.model: + raise ServiceInitializationError( + "Azure Voice Live model is required. Set via 'model' parameter " + "or 'AZURE_VOICELIVE_MODEL' environment variable." + ) + + if not settings.api_key and not credential: + raise ServiceInitializationError("Either api_key or credential must be provided for authentication.") + + super().__init__(**kwargs) + + self._endpoint = settings.endpoint + self._model = settings.model + self._api_key = settings.api_key.get_secret_value() if settings.api_key else None + self._credential = credential + self._api_version = settings.api_version or "2025-10-01" + + self._connection: Any = None + self._connection_manager: Any = None + + async def connect(self, config: RealtimeSessionConfig) -> None: + """Connect to Azure Voice Live using the SDK. + + Args: + config: Session configuration. + + Raises: + ServiceInitializationError: If connection fails. + """ + if self._api_key: + credential: Any = AzureKeyCredential(self._api_key) + elif self._credential: + credential = self._credential + else: + raise ServiceInitializationError("Authentication credential required") + + try: + self._connection_manager = vl_connect( + endpoint=self._endpoint, + credential=credential, + model=self._model, + ) + self._connection = await self._connection_manager.__aenter__() + + session = self._build_request_session(config) + await self._connection.session.update(session=session) + self._connected = True + except Exception as exc: + if self._connection_manager: + with contextlib.suppress(Exception): + await self._connection_manager.__aexit__(None, None, None) + self._connection_manager = None + self._connection = None + raise ServiceInitializationError(f"Failed to connect to Azure Voice Live: {exc}") from exc + + async def update_session(self, config: RealtimeSessionConfig) -> None: + """Update session configuration on an existing connection. + + Args: + config: New session configuration. + + Raises: + RuntimeError: If not connected. + """ + if not self._connection: + raise RuntimeError("Not connected. Call connect() first.") + session = self._build_request_session(config) + await self._connection.session.update(session=session) + + async def disconnect(self) -> None: + """Disconnect from Azure Voice Live API.""" + if self._connection_manager: + with contextlib.suppress(Exception): + await self._connection_manager.__aexit__(None, None, None) + self._connection_manager = None + self._connection = None + self._connected = False + + async def send_audio(self, audio: bytes) -> None: + """Send audio data to the model. + + Args: + audio: Raw audio bytes (PCM16, 24kHz mono recommended). + + Raises: + RuntimeError: If not connected. + """ + if not self._connection: + raise RuntimeError("Not connected. Call connect() first.") + await self._connection.input_audio_buffer.append(audio=audio) + + async def send_tool_result(self, tool_call_id: str, result: str) -> None: + """Send tool result back to the model. + + Args: + tool_call_id: ID of the tool call. + result: String result of the tool execution. + + Raises: + RuntimeError: If not connected. + """ + if not self._connection: + raise RuntimeError("Not connected. Call connect() first.") + + await self._connection.conversation.item.create( + item=FunctionCallOutputItem(call_id=tool_call_id, output=result), + ) + await self._connection.response.create() + + async def send_text(self, text: str) -> None: + """Send text input to the model. + + Args: + text: Text message to send. + + Raises: + RuntimeError: If not connected. + """ + if not self._connection: + raise RuntimeError("Not connected. Call connect() first.") + await self._connection.conversation.item.create( + item=UserMessageItem(content=[InputTextContentPart(text=text)]), + ) + await self._connection.response.create() + + async def events(self) -> AsyncIterator[RealtimeEvent]: + """Async iterator of normalized events from the session. + + Yields: + RealtimeEvent objects translated from SDK server events. + """ + if not self._connection: + return + + async for event in self._connection: + normalized = self._normalize_event(event) + if normalized: + yield normalized + + def _build_request_session(self, config: RealtimeSessionConfig) -> Any: + """Build a RequestSession from session configuration. + + Args: + config: Session configuration. + + Returns: + A RequestSession instance for the Voice Live SDK. + """ + from azure.ai.voicelive.models import ( + AudioInputTranscriptionOptions, + InputAudioFormat, + Modality, + OutputAudioFormat, + RequestSession, + ServerVad, + ) + + return RequestSession( + modalities=[Modality.TEXT, Modality.AUDIO], + input_audio_format=InputAudioFormat.PCM16, + output_audio_format=OutputAudioFormat.PCM16, + instructions=config.instructions, + input_audio_transcription=AudioInputTranscriptionOptions(model="whisper-1"), + voice=self._build_voice_config(config.voice), + tools=config.tools, # type: ignore[arg-type] + turn_detection=config.turn_detection + or ServerVad( # type: ignore[arg-type] + threshold=0.5, + prefix_padding_ms=300, + silence_duration_ms=500, + ), + ) + + def _build_voice_config(self, voice: str | None) -> Any: + """Build voice configuration for SDK. + + Voice Live supports multiple voice types: + - OpenAI voices: alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar + - Azure Neural voices: en-US-AvaNeural, etc. + + Args: + voice: Voice name or identifier. + + Returns: + String for OpenAI voices, AzureStandardVoice for Azure Neural voices. + """ + if not voice: + return "alloy" + + if voice.lower() in _OPENAI_VOICES: + return voice + + # Azure Neural voice format (e.g., en-US-AvaNeural) + if "-" in voice and "Neural" in voice: + from azure.ai.voicelive.models import AzureStandardVoice + + return AzureStandardVoice(name=voice) + + # Pass through unknown voice names + return voice + + def _normalize_event(self, event: Any) -> RealtimeEvent | None: + """Map SDK server event objects to RealtimeEvent. + + Args: + event: Typed server event from SDK connection. + + Returns: + Normalized RealtimeEvent or None if event should be ignored. + """ + event_type = event.type + + if event_type == ServerEventType.RESPONSE_AUDIO_DELTA: + audio_bytes = getattr(event, "delta", b"") + if not audio_bytes: + return None + return RealtimeEvent( + type="audio", + data={"raw_type": event_type, "audio": audio_bytes}, + ) + + if event_type in (ServerEventType.RESPONSE_AUDIO_TRANSCRIPT_DELTA, ServerEventType.RESPONSE_TEXT_DELTA): + return RealtimeEvent( + type="transcript", + data={"raw_type": event_type, "text": getattr(event, "delta", "")}, + ) + + if event_type == ServerEventType.RESPONSE_FUNCTION_CALL_ARGUMENTS_DONE: + return RealtimeEvent( + type="tool_call", + data={ + "raw_type": event_type, + "id": getattr(event, "call_id", ""), + "name": getattr(event, "name", ""), + "arguments": getattr(event, "arguments", ""), + }, + ) + + if event_type == ServerEventType.CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_COMPLETED: + return RealtimeEvent( + type="input_transcript", + data={"raw_type": event_type, "text": getattr(event, "transcript", "")}, + ) + + if event_type == ServerEventType.RESPONSE_AUDIO_TRANSCRIPT_DONE: + return RealtimeEvent( + type="response_transcript", + data={"raw_type": event_type, "text": getattr(event, "transcript", "")}, + ) + + if event_type == ServerEventType.INPUT_AUDIO_BUFFER_SPEECH_STARTED: + return RealtimeEvent(type="listening", data={"raw_type": event_type}) + + if event_type == ServerEventType.INPUT_AUDIO_BUFFER_SPEECH_STOPPED: + return RealtimeEvent(type="interrupted", data={"raw_type": event_type}) + + if event_type in (ServerEventType.RESPONSE_AUDIO_DONE, ServerEventType.RESPONSE_DONE): + return RealtimeEvent(type="speaking_done", data={"raw_type": event_type}) + + if event_type == ServerEventType.ERROR: + error_data = getattr(event, "error", None) + if error_data and hasattr(error_data, "model_dump"): + error_value = error_data.model_dump() + else: + error_value = str(error_data) if error_data else "Unknown error" + return RealtimeEvent( + type="error", + data={ + "raw_type": event_type, + "error": error_value, + }, + ) + + if event_type in (ServerEventType.SESSION_CREATED, ServerEventType.SESSION_UPDATED): + return RealtimeEvent(type="session_update", data={"raw_type": event_type}) + + return None diff --git a/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_settings.py b/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_settings.py new file mode 100644 index 0000000000..4f576ff50e --- /dev/null +++ b/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_settings.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Azure Voice Live settings.""" + +from __future__ import annotations + +from typing import ClassVar + +from agent_framework._pydantic import AFBaseSettings +from pydantic import Field, SecretStr + +__all__ = ["AzureVoiceLiveSettings"] + + +class AzureVoiceLiveSettings(AFBaseSettings): + """Settings for Azure Voice Live client. + + The settings are first loaded from environment variables with the prefix 'AZURE_VOICELIVE_'. + If the environment variables are not found, the settings can be loaded from a .env file + with the encoding 'utf-8'. If the settings are not found in the .env file, the settings + are ignored; however, validation will fail alerting that the settings are missing. + + Keyword Args: + endpoint: The endpoint of the Azure Voice Live deployment. This value + can be found in the Keys & Endpoint section when examining + your resource from the Azure portal. Supports both https:// and wss:// protocols. + Can be set via environment variable AZURE_VOICELIVE_ENDPOINT. + api_key: The API key for the Azure deployment. This value can be + found in the Keys & Endpoint section when examining your resource in + the Azure portal. You can use either KEY1 or KEY2. + Can be set via environment variable AZURE_VOICELIVE_API_KEY. + model: The name of the Azure Voice Live model deployment. This value + will correspond to the custom name you chose for your deployment + when you deployed a model. This value can be found under + Resource Management > Deployments in the Azure portal or, alternatively, + under Management > Deployments in Azure AI Foundry. + Can be set via environment variable AZURE_VOICELIVE_MODEL. + api_version: The API version to use. The default value is "2025-10-01". + Can be set via environment variable AZURE_VOICELIVE_API_VERSION. + env_file_path: The path to the .env file to load settings from. + env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. + + Examples: + .. code-block:: python + + from agent_framework_azure_voice_live import AzureVoiceLiveSettings + + # Using environment variables + # Set AZURE_VOICELIVE_ENDPOINT=https://your-endpoint.cognitiveservices.azure.com + # Set AZURE_VOICELIVE_MODEL=gpt-4o-realtime-preview + # Set AZURE_VOICELIVE_API_KEY=your-key + settings = AzureVoiceLiveSettings() + + # Or passing parameters directly + settings = AzureVoiceLiveSettings( + endpoint="https://your-endpoint.cognitiveservices.azure.com", + model="gpt-4o-realtime-preview", + api_key="your-key", + ) + + # Or loading from a .env file + settings = AzureVoiceLiveSettings(env_file_path="path/to/.env") + """ + + env_prefix: ClassVar[str] = "AZURE_VOICELIVE_" + + endpoint: str | None = Field( + None, + description="Azure Voice Live endpoint URL (https:// or wss://)", + ) + api_key: SecretStr | None = Field( + None, + description="API key for authentication", + ) + model: str | None = Field( + None, + description="Model deployment name (e.g., gpt-4o-realtime-preview)", + ) + api_version: str | None = Field( + None, + description="API version (default: 2025-10-01)", + ) diff --git a/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/py.typed b/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/packages/azure-ai-voice-live/pyproject.toml b/python/packages/azure-ai-voice-live/pyproject.toml new file mode 100644 index 0000000000..13b17ff670 --- /dev/null +++ b/python/packages/azure-ai-voice-live/pyproject.toml @@ -0,0 +1,86 @@ +[project] +name = "agent-framework-azure-voice-live" +description = "Azure Voice Live integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0b260116" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core", + "azure-ai-voicelive[aiohttp]>=1.0.0", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_azure_voice_live"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" +[tool.poe.tasks] +mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_voice_live" +test = "pytest --cov=agent_framework_azure_voice_live --cov-report=term-missing:skip-covered tests" + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/azure-ai-voice-live/tests/__init__.py b/python/packages/azure-ai-voice-live/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/packages/azure-ai-voice-live/tests/test_client.py b/python/packages/azure-ai-voice-live/tests/test_client.py new file mode 100644 index 0000000000..5f2fdb316f --- /dev/null +++ b/python/packages/azure-ai-voice-live/tests/test_client.py @@ -0,0 +1,627 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework._realtime_client import BaseRealtimeClient +from agent_framework._realtime_types import RealtimeSessionConfig +from agent_framework.exceptions import ServiceInitializationError +from pydantic import ValidationError + +from agent_framework_azure_voice_live import AzureVoiceLiveClient + + +def _make_client(): + return AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + api_key="test-key", + ) + + +def _mock_event(event_type: str, **attrs): + event = MagicMock() + event.type = event_type + for k, v in attrs.items(): + setattr(event, k, v) + return event + + +def test_inherits_base_realtime_client(): + """AzureVoiceLiveClient inherits from BaseRealtimeClient.""" + client = AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + api_key="test-key", + ) + assert isinstance(client, BaseRealtimeClient) + + +def test_requires_endpoint(): + """Raises ServiceInitializationError when endpoint is missing.""" + with pytest.raises(ServiceInitializationError, match="endpoint"): + AzureVoiceLiveClient( + model="gpt-4o-realtime-preview", + api_key="test-key", + ) + + +def test_requires_model(): + """Raises ServiceInitializationError when model is missing.""" + with pytest.raises(ServiceInitializationError, match="model"): + AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + api_key="test-key", + ) + + +def test_requires_auth(): + """Raises ServiceInitializationError when neither api_key nor credential provided.""" + with pytest.raises(ServiceInitializationError, match="api_key or credential"): + AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + ) + + +def test_settings_validation_error(): + """Raises ServiceInitializationError when settings validation fails.""" + with ( + patch( + "agent_framework_azure_voice_live._client.AzureVoiceLiveSettings", + side_effect=ValidationError.from_exception_data( + title="AzureVoiceLiveSettings", + line_errors=[], + ), + ), + pytest.raises(ServiceInitializationError, match="Failed to validate settings"), + ): + AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + api_key="test-key", + ) + + +def test_accepts_api_key(): + """Client stores api_key.""" + client = AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + api_key="test-key", + ) + assert client._api_key == "test-key" + assert client._credential is None + + +def test_accepts_credential(): + """Client stores credential.""" + mock_cred = MagicMock() + client = AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + credential=mock_cred, + ) + assert client._api_key is None + assert client._credential is mock_cred + + +def test_default_api_version(): + """Default API version is 2025-10-01.""" + client = AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + api_key="test-key", + ) + assert client._api_version == "2025-10-01" + + +def test_custom_api_version(): + """Custom API version is stored.""" + client = AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + api_key="test-key", + api_version="2026-01-01", + ) + assert client._api_version == "2026-01-01" + + +def test_settings_from_env(monkeypatch): + """Constructor uses env vars via AzureVoiceLiveSettings.""" + monkeypatch.setenv("AZURE_VOICELIVE_ENDPOINT", "https://env.services.ai.azure.com") + monkeypatch.setenv("AZURE_VOICELIVE_MODEL", "gpt-4o-realtime-env") + monkeypatch.setenv("AZURE_VOICELIVE_API_KEY", "env-key") + + client = AzureVoiceLiveClient() + assert client._endpoint == "https://env.services.ai.azure.com" + assert client._model == "gpt-4o-realtime-env" + assert client._api_key == "env-key" + + +def test_otel_provider_name(): + """OTEL_PROVIDER_NAME is set correctly.""" + assert AzureVoiceLiveClient.OTEL_PROVIDER_NAME == "azure.ai.voice_live" + + +def test_build_voice_config_openai(): + """OpenAI voices return string directly.""" + client = AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + api_key="test-key", + ) + assert client._build_voice_config("alloy") == "alloy" + assert client._build_voice_config("shimmer") == "shimmer" + assert client._build_voice_config("coral") == "coral" + + +def test_build_voice_config_azure_neural(): + """Azure Neural voices return AzureStandardVoice object.""" + client = AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + api_key="test-key", + ) + with patch("azure.ai.voicelive.models.AzureStandardVoice") as MockVoice: + MockVoice.return_value = "mock-azure-voice" + result = client._build_voice_config("en-US-AvaNeural") + MockVoice.assert_called_once_with(name="en-US-AvaNeural") + assert result == "mock-azure-voice" + + +def test_build_voice_config_default(): + """None voice returns 'alloy' default.""" + client = AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + api_key="test-key", + ) + assert client._build_voice_config(None) == "alloy" + + +def test_build_voice_config_unknown_passthrough(): + """Unknown voice names are passed through.""" + client = AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + api_key="test-key", + ) + assert client._build_voice_config("custom-voice") == "custom-voice" + + +def test_normalize_audio_event(): + """Audio delta event is normalized.""" + client = _make_client() + audio_data = b"test-audio" + event = _mock_event( + "response.audio.delta", + delta=audio_data, + ) + result = client._normalize_event(event) + assert result is not None + assert result.type == "audio" + assert result.data["audio"] == audio_data + + +def test_normalize_audio_event_empty_delta(): + """Empty audio delta returns None.""" + client = _make_client() + event = _mock_event("response.audio.delta", delta=b"") + result = client._normalize_event(event) + assert result is None + + +def test_normalize_transcript_event(): + """Transcript delta event is normalized.""" + client = _make_client() + event = _mock_event("response.audio_transcript.delta", delta="Hello world") + result = client._normalize_event(event) + assert result is not None + assert result.type == "transcript" + assert result.data["text"] == "Hello world" + + +def test_normalize_text_delta_event(): + """Text delta event is normalized as transcript.""" + client = _make_client() + event = _mock_event("response.text.delta", delta="Some text") + result = client._normalize_event(event) + assert result is not None + assert result.type == "transcript" + assert result.data["text"] == "Some text" + + +def test_normalize_tool_call_event(): + """Tool call event is normalized.""" + client = _make_client() + event = _mock_event( + "response.function_call_arguments.done", + call_id="call_123", + name="get_weather", + arguments='{"location": "Seattle"}', + ) + result = client._normalize_event(event) + assert result is not None + assert result.type == "tool_call" + assert result.data["id"] == "call_123" + assert result.data["name"] == "get_weather" + assert result.data["arguments"] == '{"location": "Seattle"}' + + +def test_normalize_input_audio_transcription_completed(): + """Input audio transcription completed maps to input_transcript.""" + client = _make_client() + event = _mock_event( + "conversation.item.input_audio_transcription.completed", + transcript="Hello, how are you?", + ) + result = client._normalize_event(event) + assert result is not None + assert result.type == "input_transcript" + assert result.data["text"] == "Hello, how are you?" + + +def test_normalize_response_audio_transcript_done(): + """Response audio transcript done maps to response_transcript.""" + client = _make_client() + event = _mock_event( + "response.audio_transcript.done", + transcript="I'm doing well, thanks!", + ) + result = client._normalize_event(event) + assert result is not None + assert result.type == "response_transcript" + assert result.data["text"] == "I'm doing well, thanks!" + + +def test_normalize_speech_started(): + """Speech started maps to listening.""" + client = _make_client() + event = _mock_event("input_audio_buffer.speech_started") + result = client._normalize_event(event) + assert result is not None + assert result.type == "listening" + + +def test_normalize_speech_stopped(): + """Speech stopped maps to interrupted.""" + client = _make_client() + event = _mock_event("input_audio_buffer.speech_stopped") + result = client._normalize_event(event) + assert result is not None + assert result.type == "interrupted" + + +def test_normalize_response_done(): + """Response done maps to speaking_done.""" + client = _make_client() + event = _mock_event("response.done") + result = client._normalize_event(event) + assert result is not None + assert result.type == "speaking_done" + + +def test_normalize_audio_done(): + """Audio done maps to speaking_done.""" + client = _make_client() + event = _mock_event("response.audio.done") + result = client._normalize_event(event) + assert result is not None + assert result.type == "speaking_done" + + +def test_normalize_error_event(): + """Error event is normalized.""" + client = _make_client() + mock_error = MagicMock() + mock_error.model_dump.return_value = {"message": "Something went wrong"} + event = _mock_event("error", error=mock_error) + result = client._normalize_event(event) + assert result is not None + assert result.type == "error" + assert result.data["error"] == {"message": "Something went wrong"} + + +def test_normalize_error_event_without_model_dump(): + """Error event with plain string error_data (no model_dump) is normalized.""" + client = _make_client() + error_obj = "Something went wrong" + event = _mock_event("error", error=error_obj) + result = client._normalize_event(event) + assert result is not None + assert result.type == "error" + assert result.data["error"] == "Something went wrong" + + +def test_normalize_error_event_with_none_error(): + """Error event with None error_data returns 'Unknown error'.""" + client = _make_client() + event = _mock_event("error", error=None) + result = client._normalize_event(event) + assert result is not None + assert result.type == "error" + assert result.data["error"] == "Unknown error" + + +def test_normalize_session_events(): + """Session created/updated map to session_update.""" + client = _make_client() + for event_type in ("session.created", "session.updated"): + event = _mock_event(event_type) + result = client._normalize_event(event) + assert result is not None + assert result.type == "session_update" + + +def test_normalize_unknown_event(): + """Unknown event returns None.""" + client = _make_client() + event = _mock_event("unknown.event.type") + result = client._normalize_event(event) + assert result is None + + +@pytest.mark.asyncio +async def test_connect_uses_sdk(): + """Connect calls SDK connect() with correct args.""" + client = _make_client() + + mock_connection = AsyncMock() + mock_connection.session = MagicMock() + mock_connection.session.update = AsyncMock() + + mock_manager = AsyncMock() + mock_manager.__aenter__ = AsyncMock(return_value=mock_connection) + mock_manager.__aexit__ = AsyncMock(return_value=False) + + with ( + patch("agent_framework_azure_voice_live._client.vl_connect", return_value=mock_manager) as mock_connect, + patch("agent_framework_azure_voice_live._client.AzureKeyCredential") as MockCred, + ): + MockCred.return_value = "mock-credential" + + assert not client.is_connected + config = RealtimeSessionConfig(instructions="Be helpful", voice="alloy") + await client.connect(config) + + mock_connect.assert_called_once_with( + endpoint="https://test.services.ai.azure.com", + credential="mock-credential", + model="gpt-4o-realtime-preview", + ) + mock_connection.session.update.assert_called_once() + assert client.is_connected + + await client.disconnect() + + +@pytest.mark.asyncio +async def test_connect_with_credential(): + """Connect uses credential directly when api_key is not set.""" + mock_cred = MagicMock() + client = AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + model="gpt-4o-realtime-preview", + credential=mock_cred, + ) + + mock_connection = AsyncMock() + mock_connection.session = MagicMock() + mock_connection.session.update = AsyncMock() + + mock_manager = AsyncMock() + mock_manager.__aenter__ = AsyncMock(return_value=mock_connection) + mock_manager.__aexit__ = AsyncMock(return_value=False) + + with patch("agent_framework_azure_voice_live._client.vl_connect", return_value=mock_manager) as mock_connect: + await client.connect(RealtimeSessionConfig(instructions="Be helpful")) + + mock_connect.assert_called_once_with( + endpoint="https://test.services.ai.azure.com", + credential=mock_cred, + model="gpt-4o-realtime-preview", + ) + assert client.is_connected + + await client.disconnect() + + +@pytest.mark.asyncio +async def test_connect_no_auth_raises(): + """Connect raises ServiceInitializationError when no credential is available.""" + client = _make_client() + client._api_key = None + client._credential = None + + with pytest.raises(ServiceInitializationError, match="Authentication credential required"): + await client.connect(RealtimeSessionConfig()) + + +@pytest.mark.asyncio +async def test_connect_failure_cleans_up(): + """Connect cleans up and raises ServiceInitializationError on failure.""" + client = _make_client() + + mock_manager = AsyncMock() + mock_manager.__aenter__ = AsyncMock(side_effect=Exception("Connection failed")) + mock_manager.__aexit__ = AsyncMock(return_value=False) + + with ( + patch("agent_framework_azure_voice_live._client.vl_connect", return_value=mock_manager), + patch("agent_framework_azure_voice_live._client.AzureKeyCredential") as MockCred, + ): + MockCred.return_value = "mock-credential" + + with pytest.raises(ServiceInitializationError, match="Failed to connect to Azure Voice Live"): + await client.connect(RealtimeSessionConfig()) + + # Verify cleanup happened + assert not client.is_connected + assert client._connection is None + assert client._connection_manager is None + + +@pytest.mark.asyncio +async def test_disconnect(): + """Disconnect cleans up connection manager and updates state.""" + client = _make_client() + + mock_manager = AsyncMock() + mock_manager.__aexit__ = AsyncMock(return_value=False) + client._connection_manager = mock_manager + client._connection = AsyncMock() + client._connected = True + + await client.disconnect() + + assert client._connection is None + assert client._connection_manager is None + assert not client.is_connected + mock_manager.__aexit__.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_audio(): + """send_audio calls connection.input_audio_buffer.append.""" + client = _make_client() + mock_connection = AsyncMock() + mock_connection.input_audio_buffer = MagicMock() + mock_connection.input_audio_buffer.append = AsyncMock() + client._connection = mock_connection + + await client.send_audio(b"test-audio-data") + + mock_connection.input_audio_buffer.append.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_audio_not_connected(): + """send_audio raises RuntimeError when not connected.""" + client = _make_client() + client._connection = None + + with pytest.raises(RuntimeError, match="Not connected. Call connect\\(\\) first."): + await client.send_audio(b"test-audio-data") + + +@pytest.mark.asyncio +async def test_send_text(): + """send_text creates conversation item and triggers response.""" + client = _make_client() + mock_connection = AsyncMock() + mock_connection.conversation = MagicMock() + mock_connection.conversation.item = MagicMock() + mock_connection.conversation.item.create = AsyncMock() + mock_connection.response = MagicMock() + mock_connection.response.create = AsyncMock() + client._connection = mock_connection + + await client.send_text("Hello") + + mock_connection.conversation.item.create.assert_called_once() + mock_connection.response.create.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_text_not_connected(): + """send_text raises RuntimeError when not connected.""" + client = _make_client() + client._connection = None + + with pytest.raises(RuntimeError, match="Not connected. Call connect\\(\\) first."): + await client.send_text("Hello") + + +@pytest.mark.asyncio +async def test_send_tool_result(): + """send_tool_result creates function output item and triggers response.""" + client = _make_client() + mock_connection = AsyncMock() + mock_connection.conversation = MagicMock() + mock_connection.conversation.item = MagicMock() + mock_connection.conversation.item.create = AsyncMock() + mock_connection.response = MagicMock() + mock_connection.response.create = AsyncMock() + client._connection = mock_connection + + await client.send_tool_result("call_123", "Result value") + + mock_connection.conversation.item.create.assert_called_once() + mock_connection.response.create.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_tool_result_not_connected(): + """send_tool_result raises RuntimeError when not connected.""" + client = _make_client() + client._connection = None + + with pytest.raises(RuntimeError, match="Not connected. Call connect\\(\\) first."): + await client.send_tool_result("call_123", "Result value") + + +@pytest.mark.asyncio +async def test_events_yields_normalized(): + """events() yields normalized events from SDK connection.""" + client = _make_client() + + audio_data = b"test-audio" + mock_event = MagicMock() + mock_event.type = "response.audio.delta" + mock_event.delta = audio_data + + # Create an async iterator that yields the mock event + async def mock_aiter(): + yield mock_event + + mock_connection = MagicMock() + mock_connection.__aiter__ = lambda self: mock_aiter() + client._connection = mock_connection + + events = [] + async for event in client.events(): + events.append(event) + + assert len(events) == 1 + assert events[0].type == "audio" + assert events[0].data["audio"] == audio_data + + +@pytest.mark.asyncio +async def test_events_no_connection(): + """events() returns immediately when not connected.""" + client = _make_client() + client._connection = None + + events = [] + async for event in client.events(): + events.append(event) + + assert len(events) == 0 + + +async def test_update_session_not_connected(): + """update_session raises RuntimeError when not connected.""" + client = _make_client() + client._connection = None + + with pytest.raises(RuntimeError, match="Not connected. Call connect\\(\\) first."): + await client.update_session(RealtimeSessionConfig(instructions="Updated")) + + +async def test_update_session_sends_update(): + """update_session calls connection.session.update with a RequestSession.""" + client = _make_client() + mock_connection = AsyncMock() + mock_connection.session = MagicMock() + mock_connection.session.update = AsyncMock() + client._connection = mock_connection + client._connected = True + + config = RealtimeSessionConfig(instructions="Updated instructions", voice="coral") + await client.update_session(config) + + mock_connection.session.update.assert_called_once() + call_kwargs = mock_connection.session.update.call_args + assert "session" in call_kwargs.kwargs diff --git a/python/packages/azure-ai-voice-live/tests/test_settings.py b/python/packages/azure-ai-voice-live/tests/test_settings.py new file mode 100644 index 0000000000..5877f7f7e0 --- /dev/null +++ b/python/packages/azure-ai-voice-live/tests/test_settings.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_azure_voice_live import AzureVoiceLiveSettings + + +def test_settings_defaults(): + """All fields default to None.""" + settings = AzureVoiceLiveSettings() + assert settings.endpoint is None + assert settings.api_key is None + assert settings.model is None + assert settings.api_version is None + + +def test_settings_from_constructor(): + """Constructor args populate fields.""" + settings = AzureVoiceLiveSettings( + endpoint="https://test.services.ai.azure.com", + api_key="test-key", + model="gpt-4o-realtime-preview", + api_version="2025-10-01", + ) + assert settings.endpoint == "https://test.services.ai.azure.com" + assert settings.api_key.get_secret_value() == "test-key" + assert settings.model == "gpt-4o-realtime-preview" + assert settings.api_version == "2025-10-01" + + +def test_settings_from_env_vars(monkeypatch): + """Environment variables populate fields via AZURE_VOICELIVE_ prefix.""" + monkeypatch.setenv("AZURE_VOICELIVE_ENDPOINT", "https://env.services.ai.azure.com") + monkeypatch.setenv("AZURE_VOICELIVE_API_KEY", "env-key") + monkeypatch.setenv("AZURE_VOICELIVE_MODEL", "gpt-4o-realtime-env") + monkeypatch.setenv("AZURE_VOICELIVE_API_VERSION", "2025-12-01") + + settings = AzureVoiceLiveSettings() + assert settings.endpoint == "https://env.services.ai.azure.com" + assert settings.api_key.get_secret_value() == "env-key" + assert settings.model == "gpt-4o-realtime-env" + assert settings.api_version == "2025-12-01" + + +def test_settings_constructor_overrides_env(monkeypatch): + """Constructor args take priority over env vars.""" + monkeypatch.setenv("AZURE_VOICELIVE_ENDPOINT", "https://env.services.ai.azure.com") + monkeypatch.setenv("AZURE_VOICELIVE_MODEL", "env-model") + + settings = AzureVoiceLiveSettings( + endpoint="https://constructor.services.ai.azure.com", + model="constructor-model", + ) + assert settings.endpoint == "https://constructor.services.ai.azure.com" + assert settings.model == "constructor-model" diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 1e408169d1..4a1f16836c 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -15,6 +15,11 @@ from ._mcp import * # noqa: F403 from ._memory import * # noqa: F403 from ._middleware import * # noqa: F403 +from ._realtime_agent import * # noqa: F403 +from ._realtime_client import * # noqa: F403 + +# Realtime voice agents +from ._realtime_types import * # noqa: F403 from ._telemetry import * # noqa: F403 from ._threads import * # noqa: F403 from ._tools import * # noqa: F403 diff --git a/python/packages/core/agent_framework/_realtime_agent.py b/python/packages/core/agent_framework/_realtime_agent.py new file mode 100644 index 0000000000..e2faada658 --- /dev/null +++ b/python/packages/core/agent_framework/_realtime_agent.py @@ -0,0 +1,205 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""RealtimeAgent for real-time voice conversations.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any, ClassVar + +from agent_framework._realtime_types import RealtimeEvent, RealtimeSessionConfig +from agent_framework._threads import AgentThread +from agent_framework._tools import FunctionTool, ToolProtocol +from agent_framework._types import ChatMessage + +from ._agents import BaseAgent + +if TYPE_CHECKING: + from agent_framework._realtime_client import RealtimeClientProtocol + +__all__ = ["RealtimeAgent", "execute_tool", "tool_to_schema"] + + +def tool_to_schema(tool: ToolProtocol) -> dict[str, Any]: + """Convert a tool to a schema dict for realtime providers. + + Args: + tool: The tool to convert. + + Returns: + A dict with type, name, description, and optional parameters. + """ + schema: dict[str, Any] = { + "type": "function", + "name": tool.name, + "description": tool.description, + } + if isinstance(tool, FunctionTool) and tool.input_model is not None: + json_schema = tool.input_model.model_json_schema() + schema["parameters"] = { + "type": "object", + "properties": json_schema.get("properties", {}), + "required": json_schema.get("required", []), + } + return schema + + +async def execute_tool( + tool_registry: dict[str, ToolProtocol], + tool_call: dict[str, Any], +) -> str: + """Execute a realtime tool call and return the string result. + + Args: + tool_registry: Mapping of tool name to tool instance. + tool_call: The tool call event data containing 'name' and 'arguments'. + + Returns: + The string result of the tool invocation, or an error message. + """ + tool_name = tool_call.get("name", "") + tool = tool_registry.get(tool_name) + if not tool: + return f"Unknown tool: {tool_name}" + + arguments_str = tool_call.get("arguments", "{}") + try: + arguments = json.loads(arguments_str) if isinstance(arguments_str, str) else arguments_str + except json.JSONDecodeError: + return f"Invalid arguments for {tool_name}: {arguments_str}" + + try: + if isinstance(tool, FunctionTool): + result = await tool.invoke(**arguments) + else: + result = str(tool) + return str(result) + except Exception as e: + return f"Error executing {tool_name}: {e}" + + +class RealtimeAgent(BaseAgent): + """Agent for real-time voice conversations.""" + + AGENT_PROVIDER_NAME: ClassVar[str] = "microsoft.agent_framework" + + def __init__( + self, + realtime_client: RealtimeClientProtocol, + instructions: str | None = None, + *, + id: str | None = None, + name: str | None = None, + description: str | None = None, + tools: list[ToolProtocol] | None = None, + voice: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a RealtimeAgent instance. + + Args: + realtime_client: The realtime client to use for audio streaming. + instructions: System instructions for the agent. + + Keyword Args: + id: Unique identifier. Auto-generated if not provided. + name: Name of the agent. + description: Description of the agent's purpose. + tools: Tools available for function calling. + voice: Voice ID for audio responses (e.g., "nova", "alloy"). + **kwargs: Additional properties passed to BaseAgent. + """ + super().__init__(id=id, name=name, description=description, **kwargs) + self._client = realtime_client + self.instructions = instructions + self._tools = tools or [] + self.voice = voice + self._tool_registry: dict[str, ToolProtocol] = {t.name: t for t in self._tools} + + async def run( + self, + audio_input: AsyncIterator[bytes], + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AsyncIterator[RealtimeEvent]: + + if not thread: + thread = self.get_new_thread() + + config = RealtimeSessionConfig( + instructions=self.instructions, + voice=self.voice, + tools=[tool_to_schema(t) for t in self._tools] if self._tools else None, + ) + + await self._client.connect(config) + + input_messages: list[ChatMessage] = [] + response_messages: list[ChatMessage] = [] + + try: + send_task = asyncio.create_task(self._send_audio_loop(audio_input)) + + try: + async for event in self._process_events(): + if event.type == "input_transcript": + text = event.data.get("text", "") + if text: + input_messages.append(ChatMessage(role="user", text=text)) + elif event.type == "response_transcript": + text = event.data.get("text", "") + if text: + response_messages.append(ChatMessage(role="assistant", text=text)) + yield event + finally: + send_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await send_task + finally: + await self._client.disconnect() + await self._notify_thread_of_new_messages(thread, input_messages, response_messages) + + async def _send_audio_loop(self, audio_input: AsyncIterator[bytes]) -> None: + try: + async for chunk in audio_input: + await self._client.send_audio(chunk) + except asyncio.CancelledError: + pass + + async def _process_events(self) -> AsyncIterator[RealtimeEvent]: + async for event in self._client.events(): + if event.type == "tool_call": + result = await self._execute_tool(event.data) + await self._client.send_tool_result(event.data["id"], result) + yield event + yield RealtimeEvent( + type="tool_result", + data={"name": event.data.get("name", ""), "result": result}, + ) + continue + yield event + + async def _execute_tool(self, tool_call: dict[str, Any]) -> str: + """Execute a tool and return the result.""" + return await execute_tool(self._tool_registry, tool_call) + + def as_tool(self, **kwargs: Any) -> Any: + """Not supported for RealtimeAgent. + + RealtimeAgent operates on audio streams, not text messages, + so it cannot be wrapped as a text-based tool for multi-agent workflows. + + Raises: + NotImplementedError: Always raised. + """ + raise NotImplementedError( + "RealtimeAgent cannot be used as a tool because it operates on audio streams. " + "Use ChatAgent for text-based multi-agent workflows." + ) + + def _tool_to_schema(self, tool: ToolProtocol) -> dict[str, Any]: + """Convert a tool to a schema dict for the provider.""" + return tool_to_schema(tool) diff --git a/python/packages/core/agent_framework/_realtime_client.py b/python/packages/core/agent_framework/_realtime_client.py new file mode 100644 index 0000000000..183ff9ba01 --- /dev/null +++ b/python/packages/core/agent_framework/_realtime_client.py @@ -0,0 +1,215 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Realtime client protocol and base implementation.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable + +from agent_framework._realtime_types import RealtimeEvent, RealtimeSessionConfig +from agent_framework._tools import ToolProtocol + +from ._serialization import SerializationMixin + +if TYPE_CHECKING: + from agent_framework._realtime_agent import RealtimeAgent + +__all__ = ["BaseRealtimeClient", "RealtimeClientProtocol"] + + +@runtime_checkable +class RealtimeClientProtocol(Protocol): + """Protocol that all realtime clients must implement. + + This defines the interface for bidirectional audio streaming with LLM providers. + Implementations handle provider-specific WebSocket protocols. + """ + + additional_properties: dict[str, Any] + + async def connect(self, config: RealtimeSessionConfig) -> None: + """Establish connection and initialize session.""" + ... + + async def disconnect(self) -> None: + """Close the connection gracefully.""" + ... + + async def send_audio(self, audio: bytes) -> None: + """Send audio data to the model.""" + ... + + async def send_tool_result(self, tool_call_id: str, result: str) -> None: + """Send the result of a tool call back to the model.""" + ... + + async def send_text(self, text: str) -> None: + """Send text input to the model.""" + ... + + async def update_session(self, config: RealtimeSessionConfig) -> None: + """Update session configuration on an existing connection. + + Note: + OpenAI and Azure OpenAI do not allow changing the voice once + assistant audio is present in the conversation. Set + ``config.voice`` to ``None`` to leave the voice unchanged. + Azure Voice Live supports voice changes at any time. + """ + ... + + def events(self) -> AsyncIterator[RealtimeEvent]: + """Async iterator of events from the session.""" + ... + + +class BaseRealtimeClient(SerializationMixin, ABC): + """Abstract base class for realtime client implementations.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "unknown" + DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"} + + def __init__( + self, + *, + additional_properties: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Initialize a BaseRealtimeClient instance. + + Keyword Args: + additional_properties: Additional properties for the client. + kwargs: Additional keyword arguments (merged into additional_properties). + """ + self._connected = False + # Merge kwargs into additional_properties + self.additional_properties = additional_properties or {} + self.additional_properties.update(kwargs) + + @property + def is_connected(self) -> bool: + """Whether the client is currently connected.""" + return self._connected + + @abstractmethod + async def connect(self, config: RealtimeSessionConfig) -> None: + """Establish connection and initialize session.""" + ... + + @abstractmethod + async def disconnect(self) -> None: + """Close the connection gracefully.""" + ... + + @abstractmethod + async def send_audio(self, audio: bytes) -> None: + """Send audio data to the model.""" + ... + + @abstractmethod + async def send_tool_result(self, tool_call_id: str, result: str) -> None: + """Send tool result back to the model.""" + ... + + async def send_text(self, text: str) -> None: + """Send text input. Optional - not all providers support this.""" + raise NotImplementedError("This client does not support text input") + + @abstractmethod + async def update_session(self, config: RealtimeSessionConfig) -> None: + """Update session configuration on an existing connection. + + Note: + OpenAI and Azure OpenAI do not allow changing the voice once + assistant audio is present in the conversation. Set + ``config.voice`` to ``None`` to leave the voice unchanged. + Azure Voice Live supports voice changes at any time. + """ + ... + + @abstractmethod + def events(self) -> AsyncIterator[RealtimeEvent]: + """Async iterator of events from the session.""" + ... + + def _build_session_config(self, config: RealtimeSessionConfig) -> dict[str, Any]: + """Translate RealtimeSessionConfig to a dict for the provider SDK. + + Args: + config: Session configuration dataclass. + + Returns: + Dict suitable for passing to session.update(). + """ + session: dict[str, Any] = { + "modalities": ["text", "audio"], + "input_audio_format": config.input_audio_format or "pcm16", + "output_audio_format": config.output_audio_format or "pcm16", + } + if config.voice: + session["voice"] = config.voice + if config.instructions: + session["instructions"] = config.instructions + if config.tools: + session["tools"] = config.tools + session["turn_detection"] = config.turn_detection or {"type": "server_vad"} + session["input_audio_transcription"] = {"model": "whisper-1"} + return session + + def as_agent( + self, + *, + id: str | None = None, + name: str | None = None, + description: str | None = None, + instructions: str | None = None, + tools: list[ToolProtocol] | None = None, + voice: str | None = None, + **kwargs: Any, + ) -> RealtimeAgent: + """Create a RealtimeAgent with this client. + + This is a convenience method that creates a RealtimeAgent instance + with this realtime client already configured. + + Keyword Args: + id: Unique identifier for the agent. + name: Name of the agent. + description: Description of the agent's purpose. + instructions: System instructions for the agent. + tools: Tools available for function calling. + voice: Voice ID for audio responses. + **kwargs: Additional properties. + + Returns: + A RealtimeAgent instance configured with this client. + + Example: + ```python + from agent_framework.openai import OpenAIRealtimeClient + + client = OpenAIRealtimeClient(api_key="sk-...") + agent = client.as_agent( + name="assistant", + instructions="You are helpful.", + voice="nova", + ) + + async for event in agent.run(audio_input): + ... + ``` + """ + from agent_framework._realtime_agent import RealtimeAgent + + return RealtimeAgent( + realtime_client=self, + id=id, + name=name, + description=description, + instructions=instructions, + tools=tools, + voice=voice, + **kwargs, + ) diff --git a/python/packages/core/agent_framework/_realtime_types.py b/python/packages/core/agent_framework/_realtime_types.py new file mode 100644 index 0000000000..043afef969 --- /dev/null +++ b/python/packages/core/agent_framework/_realtime_types.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Types for realtime voice agents.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +__all__ = ["RealtimeEvent", "RealtimeSessionConfig"] + + +@dataclass +class RealtimeEvent: + """Event emitted by a realtime client. + + Attributes: + type: Event type - one of: "audio", "transcript", "tool_call", + "interrupted", "error", "session_update", "listening", + "speaking_done", "input_transcript", "response_transcript" + data: Event-specific data payload + """ + + type: str + data: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class RealtimeSessionConfig: + """Configuration for a realtime session. + + Attributes: + instructions: System instructions for the agent + voice: Provider-specific voice ID (e.g., "nova", "alloy", "shimmer") + tools: List of tool schemas for function calling + input_audio_format: Audio format for input (e.g., "pcm16") + output_audio_format: Audio format for output (e.g., "pcm16") + turn_detection: Provider-specific VAD/turn detection settings + """ + + instructions: str | None = None + voice: str | None = None + tools: list[dict[str, Any]] | None = None + input_audio_format: str | None = None + output_audio_format: str | None = None + turn_detection: dict[str, Any] | None = None diff --git a/python/packages/core/agent_framework/azure/__init__.py b/python/packages/core/agent_framework/azure/__init__.py index 93d7dc1e0d..165fd6538d 100644 --- a/python/packages/core/agent_framework/azure/__init__.py +++ b/python/packages/core/agent_framework/azure/__init__.py @@ -29,6 +29,9 @@ "DurableAIAgentOrchestrationContext": ("agent_framework_durabletask", "agent-framework-durabletask"), "DurableAIAgentWorker": ("agent_framework_durabletask", "agent-framework-durabletask"), "get_entra_auth_token": ("agent_framework.azure._entra_id_authentication", "agent-framework-core"), + "AzureOpenAIRealtimeClient": ("agent_framework.azure._realtime_client", "agent-framework-core"), + "AzureVoiceLiveClient": ("agent_framework_azure_voice_live", "agent-framework-azure-voice-live"), + "AzureVoiceLiveSettings": ("agent_framework_azure_voice_live", "agent-framework-azure-voice-live"), } diff --git a/python/packages/core/agent_framework/azure/_realtime_client.py b/python/packages/core/agent_framework/azure/_realtime_client.py new file mode 100644 index 0000000000..3e61f2afbc --- /dev/null +++ b/python/packages/core/agent_framework/azure/_realtime_client.py @@ -0,0 +1,386 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Azure OpenAI Realtime API client using the SDK's native realtime support.""" + +from __future__ import annotations + +import base64 +import binascii +import contextlib +import logging +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping +from typing import Any, ClassVar + +from azure.core.credentials import TokenCredential +from openai.lib.azure import AsyncAzureOpenAI +from openai.resources.realtime.realtime import AsyncRealtimeConnection, AsyncRealtimeConnectionManager +from pydantic import ValidationError + +from agent_framework._realtime_client import BaseRealtimeClient +from agent_framework._realtime_types import RealtimeEvent, RealtimeSessionConfig +from agent_framework.exceptions import ServiceInitializationError + +from ._shared import DEFAULT_AZURE_API_VERSION, AzureOpenAIConfigMixin, AzureOpenAISettings + +logger = logging.getLogger(__name__) + +# The general DEFAULT_AZURE_API_VERSION (2024-10-21) predates realtime API support. +# The realtime WebSocket endpoint requires a version that includes the realtime feature. +DEFAULT_AZURE_REALTIME_API_VERSION = "2024-10-01-preview" + +__all__ = ["AzureOpenAIRealtimeClient"] + + +class AzureOpenAIRealtimeClient(AzureOpenAIConfigMixin, BaseRealtimeClient): + """Azure OpenAI Realtime API client using the SDK's native WebSocket support. + + Connects to Azure OpenAI's realtime API for bidirectional audio streaming + with GPT-4o realtime models. Uses the Azure OpenAI SDK's built-in + ``client.realtime.connect()`` for transport and authentication. + + Example: + ```python + # Using API key + client = AzureOpenAIRealtimeClient( + endpoint="https://myresource.openai.azure.com", + deployment_name="gpt-4o-realtime", + api_key="your-api-key", + ) + + # Using Azure credential + from azure.identity import DefaultAzureCredential + + client = AzureOpenAIRealtimeClient( + endpoint="https://myresource.openai.azure.com", + deployment_name="gpt-4o-realtime", + credential=DefaultAzureCredential(), + token_endpoint="https://cognitiveservices.azure.com/.default", + ) + + # Connect and start session + await client.connect( + RealtimeSessionConfig( + instructions="You are helpful.", + voice="nova", + ) + ) + + async for event in client.events(): + if event.type == "audio": + play_audio(event.data["audio"]) + ``` + """ + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" + + def __init__( + self, + *, + api_key: str | None = None, + deployment_name: str | None = None, + endpoint: str | None = None, + base_url: str | None = None, + api_version: str | None = None, + ad_token: str | None = None, + ad_token_provider: Callable[[], str | Awaitable[str]] | None = None, + token_endpoint: str | None = None, + credential: TokenCredential | None = None, + default_headers: Mapping[str, str] | None = None, + client: AsyncAzureOpenAI | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize Azure OpenAI Realtime client. + + Keyword Args: + api_key: The API key. If provided, will override the value in the env vars or .env file. + Can also be set via environment variable AZURE_OPENAI_API_KEY. + deployment_name: The deployment name. If provided, will override the value + (realtime_deployment_name) in the env vars or .env file. + Can also be set via environment variable AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME. + endpoint: The deployment endpoint. If provided will override the value + in the env vars or .env file. + Can also be set via environment variable AZURE_OPENAI_ENDPOINT. + base_url: The deployment base URL. If provided will override the value + in the env vars or .env file. + Can also be set via environment variable AZURE_OPENAI_BASE_URL. + api_version: The deployment API version. If provided will override the value + in the env vars or .env file. + Can also be set via environment variable AZURE_OPENAI_API_VERSION. + ad_token: The Azure Active Directory token. + ad_token_provider: The Azure Active Directory token provider. + token_endpoint: The token endpoint to request an Azure token. + Can also be set via environment variable AZURE_OPENAI_TOKEN_ENDPOINT. + credential: The Azure credential for authentication. + default_headers: The default headers mapping of string keys to + string values for HTTP requests. + client: An existing AsyncAzureOpenAI client to use. + env_file_path: Use the environment settings file as a fallback to using env vars. + env_file_encoding: The encoding of the environment settings file, defaults to 'utf-8'. + **kwargs: Additional keyword arguments. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureOpenAIRealtimeClient + + # Using environment variables + # Set AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com + # Set AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME=gpt-4o-realtime + # Set AZURE_OPENAI_API_KEY=your-key + client = AzureOpenAIRealtimeClient() + + # Or passing parameters directly + client = AzureOpenAIRealtimeClient( + endpoint="https://your-endpoint.openai.azure.com", + deployment_name="gpt-4o-realtime", + api_key="your-key", + ) + + # Or loading from a .env file + client = AzureOpenAIRealtimeClient(env_file_path="path/to/.env") + """ + try: + azure_openai_settings = AzureOpenAISettings( + api_key=api_key, # type: ignore[reportArgumentType] + base_url=base_url, # type: ignore[reportArgumentType] + endpoint=endpoint, # type: ignore[reportArgumentType] + realtime_deployment_name=deployment_name, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + token_endpoint=token_endpoint, + ) + except ValidationError as exc: + raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc + + if not azure_openai_settings.realtime_deployment_name: + raise ServiceInitializationError( + "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " + "or 'AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME' environment variable." + ) + + if api_version: + resolved_api_version = api_version + elif azure_openai_settings.api_version != DEFAULT_AZURE_API_VERSION: + # The settings value differs from the general default, meaning the user + # set AZURE_OPENAI_API_VERSION explicitly — respect that. + resolved_api_version = azure_openai_settings.api_version or DEFAULT_AZURE_REALTIME_API_VERSION + else: + resolved_api_version = DEFAULT_AZURE_REALTIME_API_VERSION + + super().__init__( + deployment_name=azure_openai_settings.realtime_deployment_name, + endpoint=azure_openai_settings.endpoint, + base_url=azure_openai_settings.base_url, + api_version=resolved_api_version, + api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None, + ad_token=ad_token, + ad_token_provider=ad_token_provider, + token_endpoint=azure_openai_settings.token_endpoint, + credential=credential, + default_headers=default_headers, + client=client, + **kwargs, + ) + + self._connection: AsyncRealtimeConnection | None = None + self._connection_manager: AsyncRealtimeConnectionManager | None = None + self._pending_function_names: dict[str, str] = {} + + async def connect(self, config: RealtimeSessionConfig) -> None: + """Connect to Azure OpenAI Realtime API using the SDK. + + Args: + config: Session configuration. + """ + sdk_client = await self._ensure_client() + self._connection_manager = sdk_client.realtime.connect(model=self.deployment_name) + self._connection = await self._connection_manager.__aenter__() + + session_config = self._build_session_config(config) + await self._connection.session.update(session=session_config) # type: ignore[arg-type] + + async def update_session(self, config: RealtimeSessionConfig) -> None: + """Update session configuration on an existing connection. + + Note: + Azure OpenAI does not allow changing the voice once assistant + audio is present. Set ``config.voice`` to ``None`` to leave + the voice unchanged. + + Args: + config: New session configuration. + + Raises: + RuntimeError: If not connected. + """ + if not self._connection: + raise RuntimeError("Not connected. Call connect() first.") + session_config = self._build_session_config(config) + await self._connection.session.update(session=session_config) # type: ignore[arg-type] + + async def disconnect(self) -> None: + """Disconnect from Azure OpenAI Realtime API.""" + if self._connection_manager: + with contextlib.suppress(Exception): + await self._connection_manager.__aexit__(None, None, None) + self._connection_manager = None + self._connection = None + + async def send_audio(self, audio: bytes) -> None: + """Send audio data to the model. + + Args: + audio: Raw audio bytes (PCM16, 24kHz mono recommended). + """ + if self._connection: + await self._connection.send({ + "type": "input_audio_buffer.append", + "audio": base64.b64encode(audio).decode("utf-8"), + }) + + async def send_tool_result(self, tool_call_id: str, result: str) -> None: + """Send tool result back to the model. + + Args: + tool_call_id: ID of the tool call. + result: String result of the tool execution. + """ + if self._connection: + await self._connection.send({ + "type": "conversation.item.create", + "item": { + "type": "function_call_output", + "call_id": tool_call_id, + "output": result, + }, + }) + await self._connection.send({"type": "response.create"}) + + async def send_text(self, text: str) -> None: + """Send text input to the model. + + Args: + text: Text message to send. + """ + if self._connection: + await self._connection.send({ + "type": "conversation.item.create", + "item": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}], + }, + }) + await self._connection.send({"type": "response.create"}) + + async def events(self) -> AsyncIterator[RealtimeEvent]: + """Async iterator of normalized events from the session. + + Yields: + RealtimeEvent objects translated from SDK server events. + """ + if not self._connection: + return + + async for event in self._connection: + normalized = self._normalize_event(event) + if normalized: + yield normalized + + def _normalize_event(self, event: Any) -> RealtimeEvent | None: + """Map SDK server event objects to RealtimeEvent. + + Args: + event: Typed server event from the SDK connection. + + Returns: + Normalized RealtimeEvent or None if event should be ignored. + """ + event_type = event.type + + if event_type in ("response.output_audio.delta", "response.audio.delta"): + try: + audio_bytes = base64.b64decode(event.delta) + except (ValueError, binascii.Error) as e: + logger.warning(f"Failed to decode audio delta: {e}") + return None + return RealtimeEvent( + type="audio", + data={ + "raw_type": event_type, + "audio": audio_bytes, + }, + ) + if event_type in ("response.output_audio_transcript.delta", "response.audio_transcript.delta"): + return RealtimeEvent( + type="transcript", + data={ + "raw_type": event_type, + "text": getattr(event, "delta", ""), + }, + ) + if event_type == "response.output_item.added": + item = getattr(event, "item", None) + if item and getattr(item, "type", "") == "function_call": + call_id = getattr(item, "call_id", "") + name = getattr(item, "name", "") + if call_id and name: + self._pending_function_names[call_id] = name + return None + if event_type == "response.function_call_arguments.done": + call_id = getattr(event, "call_id", "") + name = self._pending_function_names.pop(call_id, "") + return RealtimeEvent( + type="tool_call", + data={ + "raw_type": event_type, + "id": call_id, + "name": name, + "arguments": getattr(event, "arguments", ""), + }, + ) + if event_type == "conversation.item.input_audio_transcription.completed": + return RealtimeEvent( + type="input_transcript", + data={ + "raw_type": event_type, + "text": getattr(event, "transcript", "").strip(), + }, + ) + if event_type == "input_audio_buffer.speech_started": + return RealtimeEvent(type="listening", data={"raw_type": event_type}) + if event_type in ("response.output_audio.done", "response.audio.done"): + return RealtimeEvent(type="speaking_done", data={"raw_type": event_type}) + if event_type == "error": + return RealtimeEvent( + type="error", + data={ + "raw_type": event_type, + "error": event.error.model_dump() if hasattr(event.error, "model_dump") else str(event.error), + }, + ) + if event_type == "response.done": + response = getattr(event, "response", None) + if response and getattr(response, "status", None) == "failed": + details = getattr(response, "status_details", None) + error = getattr(details, "error", None) if details else None + error_data = error.model_dump() if error and hasattr(error, "model_dump") else str(error) + return RealtimeEvent( + type="error", + data={"raw_type": event_type, "error": error_data}, + ) + return None + if event_type == "conversation.item.input_audio_transcription.failed": + error = getattr(event, "error", None) + error_data = error.model_dump() if error and hasattr(error, "model_dump") else str(error) + return RealtimeEvent( + type="error", + data={"raw_type": event_type, "error": error_data}, + ) + if event_type in ("session.created", "session.updated"): + return RealtimeEvent(type="session_update", data={"raw_type": event_type}) + + logger.debug(f"Unhandled realtime event type: {event_type}") + return None diff --git a/python/packages/core/agent_framework/azure/_shared.py b/python/packages/core/agent_framework/azure/_shared.py index 8e90002a75..a9e05c7baf 100644 --- a/python/packages/core/agent_framework/azure/_shared.py +++ b/python/packages/core/agent_framework/azure/_shared.py @@ -57,6 +57,12 @@ class AzureOpenAISettings(AFBaseSettings): Resource Management > Deployments in the Azure portal or, alternatively, under Management > Deployments in Azure AI Foundry. Can be set via environment variable AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME. + realtime_deployment_name: The name of the Azure Realtime deployment. This value + will correspond to the custom name you chose for your deployment + when you deployed a model. This value can be found under + Resource Management > Deployments in the Azure portal or, alternatively, + under Management > Deployments in Azure AI Foundry. + Can be set via environment variable AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME. api_key: The API key for the Azure deployment. This value can be found in the Keys & Endpoint section when examining your resource in the Azure portal. You can use either KEY1 or KEY2. @@ -103,6 +109,7 @@ class AzureOpenAISettings(AFBaseSettings): chat_deployment_name: str | None = None responses_deployment_name: str | None = None + realtime_deployment_name: str | None = None endpoint: HTTPsUrl | None = None base_url: HTTPsUrl | None = None api_key: SecretStr | None = None diff --git a/python/packages/core/agent_framework/openai/__init__.py b/python/packages/core/agent_framework/openai/__init__.py index 008e2cb54c..db493b81b2 100644 --- a/python/packages/core/agent_framework/openai/__init__.py +++ b/python/packages/core/agent_framework/openai/__init__.py @@ -4,5 +4,6 @@ from ._assistants_client import * # noqa: F403 from ._chat_client import * # noqa: F403 from ._exceptions import * # noqa: F403 +from ._realtime_client import * # noqa: F403 from ._responses_client import * # noqa: F403 from ._shared import * # noqa: F403 diff --git a/python/packages/core/agent_framework/openai/_realtime_client.py b/python/packages/core/agent_framework/openai/_realtime_client.py new file mode 100644 index 0000000000..e5c104b13e --- /dev/null +++ b/python/packages/core/agent_framework/openai/_realtime_client.py @@ -0,0 +1,350 @@ +# Copyright (c) Microsoft. All rights reserved. +"""OpenAI Realtime API client using the SDK's native realtime support.""" + +from __future__ import annotations + +import base64 +import binascii +import contextlib +import logging +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping +from typing import TYPE_CHECKING, Any, ClassVar, cast + +from agent_framework._realtime_client import BaseRealtimeClient +from agent_framework._realtime_types import RealtimeEvent, RealtimeSessionConfig +from agent_framework.exceptions import ServiceInitializationError + +from ._shared import OpenAIConfigMixin, OpenAISettings + +if TYPE_CHECKING: + from openai import AsyncOpenAI + from openai.resources.realtime.realtime import AsyncRealtimeConnection, AsyncRealtimeConnectionManager + +logger = logging.getLogger(__name__) + +__all__ = ["OpenAIRealtimeClient"] + + +class OpenAIRealtimeClient(OpenAIConfigMixin, BaseRealtimeClient): + """OpenAI Realtime API client using the SDK's native WebSocket support. + + Connects to OpenAI's realtime API for bidirectional audio streaming + with GPT-4o realtime models. Uses the OpenAI SDK's built-in + ``client.realtime.connect()`` for transport and authentication. + + Example: + ```python + client = OpenAIRealtimeClient(api_key="sk-...") + await client.connect( + RealtimeSessionConfig( + instructions="You are helpful.", + voice="nova", + ) + ) + + async for event in client.events(): + if event.type == "audio": + play_audio(event.data["audio"]) + ``` + """ + + OTEL_PROVIDER_NAME: ClassVar[str] = "openai" + + def __init__( + self, + *, + model_id: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + default_headers: Mapping[str, str] | None = None, + client: AsyncOpenAI | None = None, + base_url: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize OpenAI Realtime client. + + Keyword Args: + model_id: Model ID for realtime conversations (default: gpt-4o-realtime-preview). + Can also be set via OPENAI_CHAT_MODEL_ID environment variable. + api_key: OpenAI API key. Can also be set via OPENAI_API_KEY env var. + org_id: OpenAI organization ID. + default_headers: Default headers for HTTP requests. + client: An existing AsyncOpenAI client instance. + base_url: Override base URL for the API. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding of the .env file. + **kwargs: Additional keyword arguments. + """ + from pydantic import ValidationError + + try: + openai_settings = OpenAISettings( + api_key=api_key, # type: ignore[reportArgumentType] + base_url=base_url, + org_id=org_id, + chat_model_id=model_id, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + except ValidationError as ex: + raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex + + if not client and not openai_settings.api_key: + raise ServiceInitializationError( + "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." + ) + + resolved_model = openai_settings.chat_model_id or "gpt-4o-realtime-preview" + + super().__init__( + model_id=resolved_model, + api_key=self._get_api_key(openai_settings.api_key), + base_url=openai_settings.base_url if openai_settings.base_url else None, + org_id=openai_settings.org_id, + default_headers=default_headers, + client=client, + **kwargs, + ) + + self._connection: AsyncRealtimeConnection | None = None + self._connection_manager: AsyncRealtimeConnectionManager | None = None + self._pending_function_names: dict[str, str] = {} + + def _build_session_config(self, config: RealtimeSessionConfig) -> dict[str, Any]: + """Translate RealtimeSessionConfig to OpenAI GA API format. + + The GA realtime API requires ``type: "realtime"``, uses ``output_modalities`` + instead of ``modalities``, and nests audio settings under ``audio.input`` / + ``audio.output``. + + Args: + config: Session configuration dataclass. + + Returns: + Dict conforming to ``RealtimeSessionCreateRequestParam``. + """ + input_audio: dict[str, Any] = { + "format": {"type": "audio/pcm", "rate": 24000}, + "transcription": {"model": "whisper-1"}, + "turn_detection": config.turn_detection or {"type": "server_vad"}, + } + + output_audio: dict[str, Any] = { + "format": {"type": "audio/pcm", "rate": 24000}, + } + if config.voice: + output_audio["voice"] = config.voice + + session: dict[str, Any] = { + "type": "realtime", + "output_modalities": ["audio"], + "audio": { + "input": input_audio, + "output": output_audio, + }, + } + if config.instructions: + session["instructions"] = config.instructions + if config.tools: + session["tools"] = config.tools + return session + + async def connect(self, config: RealtimeSessionConfig) -> None: + """Connect to OpenAI Realtime API using the SDK. + + Args: + config: Session configuration. + """ + sdk_client = await self._ensure_client() + # model_id is guaranteed to be str by __init__ (defaults to "gpt-4o-realtime-preview") + self._connection_manager = sdk_client.realtime.connect(model=cast(str, self.model_id)) + self._connection = await self._connection_manager.__aenter__() + + session_config = self._build_session_config(config) + await self._connection.session.update(session=session_config) # type: ignore[arg-type] + + async def update_session(self, config: RealtimeSessionConfig) -> None: + """Update session configuration on an existing connection. + + Note: + OpenAI does not allow changing the voice once assistant audio + is present. Set ``config.voice`` to ``None`` to leave the + voice unchanged. + + Args: + config: New session configuration. + + Raises: + RuntimeError: If not connected. + """ + if not self._connection: + raise RuntimeError("Not connected. Call connect() first.") + session_config = self._build_session_config(config) + await self._connection.session.update(session=session_config) # type: ignore[arg-type] + + async def disconnect(self) -> None: + """Disconnect from OpenAI Realtime API.""" + if self._connection_manager: + with contextlib.suppress(Exception): + await self._connection_manager.__aexit__(None, None, None) + self._connection_manager = None + self._connection = None + + async def send_audio(self, audio: bytes) -> None: + """Send audio data to the model. + + Args: + audio: Raw audio bytes (PCM16, 24kHz mono recommended). + """ + if self._connection: + await self._connection.send({ + "type": "input_audio_buffer.append", + "audio": base64.b64encode(audio).decode("utf-8"), + }) + + async def send_tool_result(self, tool_call_id: str, result: str) -> None: + """Send tool result back to the model. + + Args: + tool_call_id: ID of the tool call. + result: String result of the tool execution. + """ + if self._connection: + await self._connection.send({ + "type": "conversation.item.create", + "item": { + "type": "function_call_output", + "call_id": tool_call_id, + "output": result, + }, + }) + await self._connection.send({"type": "response.create"}) + + async def send_text(self, text: str) -> None: + """Send text input to the model. + + Args: + text: Text message to send. + """ + if self._connection: + await self._connection.send({ + "type": "conversation.item.create", + "item": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}], + }, + }) + await self._connection.send({"type": "response.create"}) + + async def events(self) -> AsyncIterator[RealtimeEvent]: + """Async iterator of normalized events from the session. + + Yields: + RealtimeEvent objects translated from SDK server events. + """ + if not self._connection: + return + + async for event in self._connection: + normalized = self._normalize_event(event) + if normalized: + yield normalized + + def _normalize_event(self, event: Any) -> RealtimeEvent | None: + """Map SDK server event objects to RealtimeEvent. + + Args: + event: Typed server event from the SDK connection. + + Returns: + Normalized RealtimeEvent or None if event should be ignored. + """ + event_type = event.type + + if event_type in ("response.output_audio.delta", "response.audio.delta"): + try: + audio_bytes = base64.b64decode(event.delta) + except (ValueError, binascii.Error) as e: + logger.warning(f"Failed to decode audio delta: {e}") + return None + return RealtimeEvent( + type="audio", + data={ + "raw_type": event_type, + "audio": audio_bytes, + }, + ) + if event_type in ("response.output_audio_transcript.delta", "response.audio_transcript.delta"): + return RealtimeEvent( + type="transcript", + data={ + "raw_type": event_type, + "text": getattr(event, "delta", ""), + }, + ) + if event_type == "response.output_item.added": + item = getattr(event, "item", None) + if item and getattr(item, "type", "") == "function_call": + call_id = getattr(item, "call_id", "") + name = getattr(item, "name", "") + if call_id and name: + self._pending_function_names[call_id] = name + return None + if event_type == "response.function_call_arguments.done": + call_id = getattr(event, "call_id", "") + name = self._pending_function_names.pop(call_id, "") + return RealtimeEvent( + type="tool_call", + data={ + "raw_type": event_type, + "id": call_id, + "name": name, + "arguments": getattr(event, "arguments", ""), + }, + ) + if event_type == "conversation.item.input_audio_transcription.completed": + return RealtimeEvent( + type="input_transcript", + data={ + "raw_type": event_type, + "text": getattr(event, "transcript", "").strip(), + }, + ) + if event_type == "input_audio_buffer.speech_started": + return RealtimeEvent(type="listening", data={"raw_type": event_type}) + if event_type in ("response.output_audio.done", "response.audio.done"): + return RealtimeEvent(type="speaking_done", data={"raw_type": event_type}) + if event_type == "error": + return RealtimeEvent( + type="error", + data={ + "raw_type": event_type, + "error": event.error.model_dump() if hasattr(event.error, "model_dump") else str(event.error), + }, + ) + if event_type == "response.done": + response = getattr(event, "response", None) + if response and getattr(response, "status", None) == "failed": + details = getattr(response, "status_details", None) + error = getattr(details, "error", None) if details else None + error_data = error.model_dump() if error and hasattr(error, "model_dump") else str(error) + return RealtimeEvent( + type="error", + data={"raw_type": event_type, "error": error_data}, + ) + return None + if event_type == "conversation.item.input_audio_transcription.failed": + error = getattr(event, "error", None) + error_data = error.model_dump() if error and hasattr(error, "model_dump") else str(error) + return RealtimeEvent( + type="error", + data={"raw_type": event_type, "error": error_data}, + ) + if event_type in ("session.created", "session.updated"): + return RealtimeEvent(type="session_update", data={"raw_type": event_type}) + + logger.debug(f"Unhandled realtime event type: {event_type}") + return None diff --git a/python/packages/core/tests/azure/test_azure_openai_settings_realtime.py b/python/packages/core/tests/azure/test_azure_openai_settings_realtime.py new file mode 100644 index 0000000000..2168a76b84 --- /dev/null +++ b/python/packages/core/tests/azure/test_azure_openai_settings_realtime.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Tests for AzureOpenAISettings realtime_deployment_name field.""" + +from agent_framework.azure._shared import AzureOpenAISettings + + +def test_realtime_deployment_name_default_none(): + """Test realtime_deployment_name defaults to None.""" + settings = AzureOpenAISettings() + assert settings.realtime_deployment_name is None + + +def test_realtime_deployment_name_from_constructor(): + """Test realtime_deployment_name can be set via constructor.""" + settings = AzureOpenAISettings(realtime_deployment_name="gpt-4o-realtime") + assert settings.realtime_deployment_name == "gpt-4o-realtime" + + +def test_realtime_deployment_name_from_env(monkeypatch): + """Test realtime_deployment_name can be set via environment variable.""" + monkeypatch.setenv("AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME", "gpt-4o-realtime-env") + settings = AzureOpenAISettings() + assert settings.realtime_deployment_name == "gpt-4o-realtime-env" diff --git a/python/packages/core/tests/azure/test_azure_realtime_client.py b/python/packages/core/tests/azure/test_azure_realtime_client.py new file mode 100644 index 0000000000..ebef416247 --- /dev/null +++ b/python/packages/core/tests/azure/test_azure_realtime_client.py @@ -0,0 +1,233 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Tests for AzureOpenAIRealtimeClient.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agent_framework._realtime_client import RealtimeClientProtocol +from agent_framework._realtime_types import RealtimeSessionConfig +from agent_framework.azure._realtime_client import DEFAULT_AZURE_REALTIME_API_VERSION, AzureOpenAIRealtimeClient +from agent_framework.azure._shared import DEFAULT_AZURE_API_VERSION, AzureOpenAIConfigMixin +from agent_framework.exceptions import ServiceInitializationError + + +def test_azure_realtime_client_implements_protocol(): + """Test AzureOpenAIRealtimeClient satisfies RealtimeClientProtocol.""" + client = AzureOpenAIRealtimeClient( + endpoint="https://test.openai.azure.com", + deployment_name="gpt-4o-realtime", + api_key="test-key", + ) + assert isinstance(client, RealtimeClientProtocol) + + +def test_azure_realtime_client_extends_config_mixin(): + """Test AzureOpenAIRealtimeClient extends AzureOpenAIConfigMixin.""" + assert issubclass(AzureOpenAIRealtimeClient, AzureOpenAIConfigMixin) + + +def test_azure_realtime_client_with_api_key(): + """Test client can be created with API key.""" + client = AzureOpenAIRealtimeClient( + endpoint="https://test.openai.azure.com", + deployment_name="gpt-4o-realtime", + api_key="test-key", + ) + assert client.endpoint == "https://test.openai.azure.com/" + assert client.deployment_name == "gpt-4o-realtime" + + +def test_azure_realtime_client_with_credential(): + """Test client can be created with Azure credential.""" + mock_credential = MagicMock() + # Need to mock get_entra_auth_token since credential path requires token_endpoint + with patch("agent_framework.azure._shared.get_entra_auth_token", return_value="mock-token"): + client = AzureOpenAIRealtimeClient( + endpoint="https://test.openai.azure.com", + deployment_name="gpt-4o-realtime", + credential=mock_credential, + token_endpoint="https://cognitiveservices.azure.com/.default", + ) + assert client.deployment_name == "gpt-4o-realtime" + + +def test_azure_realtime_client_with_ad_token(): + """Test client can be created with AD token.""" + client = AzureOpenAIRealtimeClient( + endpoint="https://test.openai.azure.com", + deployment_name="gpt-4o-realtime", + ad_token="test-ad-token", + ) + assert client.deployment_name == "gpt-4o-realtime" + + +def test_azure_realtime_client_with_ad_token_provider(): + """Test client can be created with AD token provider.""" + + def token_provider() -> str: + return "test-token" + + client = AzureOpenAIRealtimeClient( + endpoint="https://test.openai.azure.com", + deployment_name="gpt-4o-realtime", + ad_token_provider=token_provider, + ) + assert client.deployment_name == "gpt-4o-realtime" + + +def test_azure_realtime_client_requires_deployment_name(): + """Test client requires deployment_name.""" + with pytest.raises(ServiceInitializationError, match="deployment name is required"): + AzureOpenAIRealtimeClient( + endpoint="https://test.openai.azure.com", + api_key="test-key", + ) + + +def test_azure_realtime_client_requires_auth(): + """Test client requires authentication (api_key, ad_token, ad_token_provider, or credential).""" + with pytest.raises(ServiceInitializationError, match="api_key, ad_token or ad_token_provider"): + AzureOpenAIRealtimeClient( + endpoint="https://test.openai.azure.com", + deployment_name="gpt-4o-realtime", + ) + + +def test_azure_realtime_client_settings_fallback(): + """Test client falls back to environment variable for deployment name.""" + with patch.dict("os.environ", {"AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME": "env-deployment"}): + client = AzureOpenAIRealtimeClient( + endpoint="https://test.openai.azure.com", + api_key="test-key", + ) + assert client.deployment_name == "env-deployment" + + +def test_azure_realtime_client_with_existing_client(): + """Test client can be created with an existing AsyncAzureOpenAI client.""" + mock_client = MagicMock() + client = AzureOpenAIRealtimeClient( + deployment_name="gpt-4o-realtime", + client=mock_client, + ) + assert client.deployment_name == "gpt-4o-realtime" + + +@pytest.mark.asyncio +async def test_azure_realtime_client_connect_uses_sdk(): + """Test connect() uses SDK's client.realtime.connect().""" + mock_sdk_client = MagicMock() + mock_connection_manager = MagicMock() + mock_connection = AsyncMock() + mock_connection.send = AsyncMock() + + mock_connection_manager.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection_manager.__aexit__ = AsyncMock() + mock_sdk_client.realtime.connect.return_value = mock_connection_manager + + client = AzureOpenAIRealtimeClient( + deployment_name="gpt-4o-realtime", + client=mock_sdk_client, + ) + + config = RealtimeSessionConfig( + instructions="You are helpful.", + voice="nova", + ) + + await client.connect(config) + + # Verify SDK's realtime.connect() was called + mock_sdk_client.realtime.connect.assert_called_once() + call_kwargs = mock_sdk_client.realtime.connect.call_args.kwargs + assert call_kwargs["model"] == "gpt-4o-realtime" + + # Verify session.update was sent + mock_connection.session.update.assert_called_once() + call_kwargs = mock_connection.session.update.call_args.kwargs + assert call_kwargs["session"]["instructions"] == "You are helpful." + assert call_kwargs["session"]["voice"] == "nova" + + +def test_azure_realtime_client_has_additional_properties(): + """Test AzureOpenAIRealtimeClient has additional_properties.""" + client = AzureOpenAIRealtimeClient( + endpoint="https://test.openai.azure.com", + deployment_name="gpt-4o-realtime", + api_key="test-key", + ) + assert hasattr(client, "additional_properties") + assert isinstance(client.additional_properties, dict) + + +def test_azure_realtime_client_otel_provider_name(): + """Test AzureOpenAIRealtimeClient has OTEL_PROVIDER_NAME.""" + assert AzureOpenAIRealtimeClient.OTEL_PROVIDER_NAME == "azure.ai.openai" + + +def test_azure_realtime_client_defaults_to_realtime_api_version(): + """Test client defaults to a realtime-compatible API version, not the general default.""" + client = AzureOpenAIRealtimeClient( + endpoint="https://test.openai.azure.com", + deployment_name="gpt-4o-realtime", + api_key="test-key", + ) + assert client.api_version == DEFAULT_AZURE_REALTIME_API_VERSION + assert client.api_version != DEFAULT_AZURE_API_VERSION + + +def test_azure_realtime_client_respects_explicit_api_version(): + """Test client uses an explicitly provided API version.""" + client = AzureOpenAIRealtimeClient( + endpoint="https://test.openai.azure.com", + deployment_name="gpt-4o-realtime", + api_key="test-key", + api_version="2025-01-01", + ) + assert client.api_version == "2025-01-01" + + +async def test_azure_realtime_update_session_not_connected(): + """Test update_session raises RuntimeError when not connected.""" + client = AzureOpenAIRealtimeClient( + endpoint="https://test.openai.azure.com", + deployment_name="gpt-4o-realtime", + api_key="test-key", + ) + config = RealtimeSessionConfig(instructions="Updated instructions.") + + with pytest.raises(RuntimeError, match="Not connected"): + await client.update_session(config) + + +async def test_azure_realtime_update_session_sends_update(): + """Test update_session calls connection.session.update with correct config.""" + mock_sdk_client = MagicMock() + mock_connection_manager = MagicMock() + mock_connection = AsyncMock() + + mock_connection_manager.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection_manager.__aexit__ = AsyncMock() + mock_sdk_client.realtime.connect.return_value = mock_connection_manager + + client = AzureOpenAIRealtimeClient( + deployment_name="gpt-4o-realtime", + client=mock_sdk_client, + ) + + # Connect first + connect_config = RealtimeSessionConfig(instructions="Initial.", voice="nova") + await client.connect(connect_config) + + # Reset mock to isolate update_session call + mock_connection.session.update.reset_mock() + + # Update session with new config + update_config = RealtimeSessionConfig(instructions="Updated instructions.", voice="alloy") + await client.update_session(update_config) + + mock_connection.session.update.assert_called_once() + call_kwargs = mock_connection.session.update.call_args.kwargs + assert call_kwargs["session"]["instructions"] == "Updated instructions." + assert call_kwargs["session"]["voice"] == "alloy" diff --git a/python/packages/core/tests/core/test_realtime_agent.py b/python/packages/core/tests/core/test_realtime_agent.py new file mode 100644 index 0000000000..7144db0f19 --- /dev/null +++ b/python/packages/core/tests/core/test_realtime_agent.py @@ -0,0 +1,529 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import AsyncIterator + +import pytest + +from agent_framework import tool +from agent_framework._realtime_agent import RealtimeAgent +from agent_framework._realtime_client import RealtimeClientProtocol +from agent_framework._realtime_types import RealtimeEvent, RealtimeSessionConfig +from agent_framework._threads import AgentThread + + +@tool +def get_weather(location: str) -> str: + """Get weather for a location.""" + return f"Weather in {location}: Sunny, 72F" + + +class MockRealtimeClient: + """Mock client for testing RealtimeAgent.""" + + def __init__(self, events_to_yield: list[RealtimeEvent] | None = None): + self._connected = False + self._events_to_yield = events_to_yield or [] + self._config: RealtimeSessionConfig | None = None + self._sent_audio: list[bytes] = [] + self._tool_results: list[tuple[str, str]] = [] + self.additional_properties: dict = {} + + async def connect(self, config: RealtimeSessionConfig) -> None: + self._connected = True + self._config = config + + async def disconnect(self) -> None: + self._connected = False + + async def send_audio(self, audio: bytes) -> None: + self._sent_audio.append(audio) + + async def send_tool_result(self, tool_call_id: str, result: str) -> None: + self._tool_results.append((tool_call_id, result)) + + async def send_text(self, text: str) -> None: + pass + + async def update_session(self, config: RealtimeSessionConfig) -> None: + pass + + async def events(self) -> AsyncIterator[RealtimeEvent]: + for event in self._events_to_yield: + yield event + + @property + def is_connected(self) -> bool: + return self._connected + + +def test_realtime_agent_creation(): + """Test RealtimeAgent can be created with minimal config.""" + client = MockRealtimeClient() + agent = RealtimeAgent(realtime_client=client) + assert agent._client is client + assert agent.name is None + assert agent.instructions is None + + +def test_realtime_agent_with_full_config(): + """Test RealtimeAgent accepts all configuration options.""" + client = MockRealtimeClient() + agent = RealtimeAgent( + realtime_client=client, + name="TestAgent", + instructions="Be helpful.", + voice="nova", + ) + assert agent.name == "TestAgent" + assert agent.instructions == "Be helpful." + assert agent.voice == "nova" + + +@pytest.mark.asyncio +async def test_realtime_agent_run_connects_client(): + """Test that run() connects the client.""" + client = MockRealtimeClient(events_to_yield=[RealtimeEvent(type="speaking_done", data={})]) + agent = RealtimeAgent(realtime_client=client, instructions="Test") + + async def empty_audio(): + return + yield # Make it an async generator + + events = [] + async for event in agent.run(audio_input=empty_audio()): + events.append(event) + + # Client should have been connected with config + assert client._config is not None + assert client._config.instructions == "Test" + # Client should be disconnected after run completes + assert not client.is_connected + + +@pytest.mark.asyncio +async def test_realtime_agent_yields_events(): + """Test that run() yields events from the client.""" + expected_events = [ + RealtimeEvent(type="audio", data={"chunk": 1}), + RealtimeEvent(type="transcript", data={"text": "Hello"}), + ] + client = MockRealtimeClient(events_to_yield=expected_events) + agent = RealtimeAgent(realtime_client=client) + + async def empty_audio(): + return + yield + + received = [] + async for event in agent.run(audio_input=empty_audio()): + received.append(event) + + assert len(received) == 2 + assert received[0].type == "audio" + assert received[1].type == "transcript" + + +@pytest.mark.asyncio +async def test_realtime_agent_executes_tools(): + """Test that RealtimeAgent executes tool calls.""" + tool_call_event = RealtimeEvent( + type="tool_call", data={"id": "call_123", "name": "get_weather", "arguments": '{"location": "Seattle"}'} + ) + client = MockRealtimeClient( + events_to_yield=[ + tool_call_event, + RealtimeEvent(type="speaking_done", data={}), + ] + ) + + agent = RealtimeAgent( + realtime_client=client, + tools=[get_weather], + ) + + async def empty_audio(): + return + yield + + events = [] + async for event in agent.run(audio_input=empty_audio()): + events.append(event) + + # Should have sent tool result back + assert len(client._tool_results) == 1 + tool_call_id, result = client._tool_results[0] + assert tool_call_id == "call_123" + assert "Seattle" in result + assert "Sunny" in result + + +@pytest.mark.asyncio +async def test_realtime_agent_unknown_tool(): + """Test that RealtimeAgent handles unknown tool calls gracefully.""" + tool_call_event = RealtimeEvent( + type="tool_call", data={"id": "call_456", "name": "unknown_tool", "arguments": "{}"} + ) + client = MockRealtimeClient( + events_to_yield=[ + tool_call_event, + RealtimeEvent(type="speaking_done", data={}), + ] + ) + + agent = RealtimeAgent(realtime_client=client) + + async def empty_audio(): + return + yield + + async for _ in agent.run(audio_input=empty_audio()): + pass + + assert len(client._tool_results) == 1 + _, result = client._tool_results[0] + assert "Unknown tool" in result + + +@pytest.mark.asyncio +async def test_realtime_agent_streams_audio(): + """Test that RealtimeAgent streams audio to client.""" + import asyncio + + # Track when audio is sent + audio_sent_event = asyncio.Event() + + class AudioTrackingClient(MockRealtimeClient): + async def events(self) -> AsyncIterator[RealtimeEvent]: + # Wait for audio to be sent before yielding events + await audio_sent_event.wait() + for event in self._events_to_yield: + yield event + + client = AudioTrackingClient( + events_to_yield=[ + RealtimeEvent(type="speaking_done", data={}), + ] + ) + agent = RealtimeAgent(realtime_client=client) + + audio_chunks = [b"chunk1", b"chunk2", b"chunk3"] + + async def audio_generator(): + for chunk in audio_chunks: + yield chunk + # Signal that all audio has been sent + audio_sent_event.set() + + async for _ in agent.run(audio_input=audio_generator()): + pass + + # All audio chunks should have been sent + assert client._sent_audio == audio_chunks + + +@pytest.mark.asyncio +async def test_realtime_agent_invalid_tool_arguments(): + """Test that RealtimeAgent handles invalid JSON in tool arguments.""" + tool_call_event = RealtimeEvent( + type="tool_call", data={"id": "call_789", "name": "get_weather", "arguments": "invalid json"} + ) + client = MockRealtimeClient( + events_to_yield=[ + tool_call_event, + RealtimeEvent(type="speaking_done", data={}), + ] + ) + + agent = RealtimeAgent(realtime_client=client, tools=[get_weather]) + + async def empty_audio(): + return + yield + + async for _ in agent.run(audio_input=empty_audio()): + pass + + assert len(client._tool_results) == 1 + _, result = client._tool_results[0] + assert "Invalid arguments" in result + + +@tool +def calculator(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@pytest.mark.asyncio +async def test_realtime_agent_multiple_tools(): + """Test that RealtimeAgent can handle multiple tool calls in sequence.""" + client = MockRealtimeClient( + events_to_yield=[ + RealtimeEvent( + type="tool_call", data={"id": "call_1", "name": "get_weather", "arguments": '{"location": "NYC"}'} + ), + RealtimeEvent( + type="tool_call", data={"id": "call_2", "name": "calculator", "arguments": '{"a": 5, "b": 3}'} + ), + RealtimeEvent(type="speaking_done", data={}), + ] + ) + + agent = RealtimeAgent( + realtime_client=client, + tools=[get_weather, calculator], + ) + + async def empty_audio(): + return + yield + + async for _ in agent.run(audio_input=empty_audio()): + pass + + assert len(client._tool_results) == 2 + # First result should be weather + assert "NYC" in client._tool_results[0][1] + # Second result should be calculation + assert "8" in client._tool_results[1][1] + + +@pytest.mark.asyncio +async def test_realtime_agent_tools_passed_to_config(): + """Test that tools are correctly passed to session config.""" + client = MockRealtimeClient( + events_to_yield=[ + RealtimeEvent(type="speaking_done", data={}), + ] + ) + + agent = RealtimeAgent( + realtime_client=client, + tools=[get_weather], + ) + + async def empty_audio(): + return + yield + + async for _ in agent.run(audio_input=empty_audio()): + pass + + # Verify tools were passed in config + assert client._config is not None + assert client._config.tools is not None + assert len(client._config.tools) == 1 + assert client._config.tools[0]["name"] == "get_weather" + + +@pytest.mark.asyncio +async def test_realtime_agent_voice_passed_to_config(): + """Test that voice setting is correctly passed to session config.""" + client = MockRealtimeClient( + events_to_yield=[ + RealtimeEvent(type="speaking_done", data={}), + ] + ) + + agent = RealtimeAgent( + realtime_client=client, + voice="alloy", + ) + + async def empty_audio(): + return + yield + + async for _ in agent.run(audio_input=empty_audio()): + pass + + assert client._config is not None + assert client._config.voice == "alloy" + + +@pytest.mark.asyncio +async def test_realtime_agent_client_protocol_compliance(): + """Test that MockRealtimeClient satisfies RealtimeClientProtocol.""" + client = MockRealtimeClient() + # This should pass type checking - verifies protocol compliance + assert isinstance(client, RealtimeClientProtocol) + + +def test_realtime_agent_extends_base_agent(): + """Test RealtimeAgent extends BaseAgent.""" + from agent_framework._agents import BaseAgent + from agent_framework._realtime_agent import RealtimeAgent + + assert issubclass(RealtimeAgent, BaseAgent) + + +def test_realtime_agent_has_id(): + """Test RealtimeAgent has auto-generated id.""" + client = MockRealtimeClient() + agent = RealtimeAgent(realtime_client=client) + + assert hasattr(agent, "id") + assert agent.id is not None + assert len(agent.id) > 0 + + +def test_realtime_agent_has_description(): + """Test RealtimeAgent accepts description.""" + client = MockRealtimeClient() + agent = RealtimeAgent( + realtime_client=client, + name="Test", + description="A test agent", + ) + + assert agent.description == "A test agent" + + +def test_realtime_agent_serialization(): + """Test RealtimeAgent can be serialized.""" + client = MockRealtimeClient() + agent = RealtimeAgent( + realtime_client=client, + name="TestAgent", + instructions="Be helpful", + ) + + serialized = agent.to_dict() + assert serialized["name"] == "TestAgent" + assert "id" in serialized + + +def test_realtime_agent_as_tool_raises(): + """Test RealtimeAgent.as_tool() raises NotImplementedError.""" + client = MockRealtimeClient() + agent = RealtimeAgent( + realtime_client=client, + name="voice-assistant", + description="A voice assistant", + ) + + with pytest.raises(NotImplementedError) as exc_info: + agent.as_tool() + + assert "audio streams" in str(exc_info.value) + assert "ChatAgent" in str(exc_info.value) + + +def test_realtime_agent_has_provider_name(): + """Test RealtimeAgent has AGENT_PROVIDER_NAME for telemetry.""" + assert hasattr(RealtimeAgent, "AGENT_PROVIDER_NAME") + assert RealtimeAgent.AGENT_PROVIDER_NAME == "microsoft.agent_framework" + + +def test_realtime_agent_exported_from_package(): + """Test RealtimeAgent is exported from main package.""" + from agent_framework import RealtimeAgent as ExportedRealtimeAgent + + assert ExportedRealtimeAgent is RealtimeAgent + + +@pytest.mark.asyncio +async def test_realtime_agent_stores_transcripts_in_thread(): + """Test that run() stores input and response transcripts in the thread.""" + client = MockRealtimeClient( + events_to_yield=[ + RealtimeEvent(type="input_transcript", data={"text": "What is the weather?"}), + RealtimeEvent(type="response_transcript", data={"text": "It is sunny today."}), + RealtimeEvent(type="speaking_done", data={}), + ] + ) + agent = RealtimeAgent(realtime_client=client) + thread = AgentThread() + + async def empty_audio(): + return + yield + + async for _ in agent.run(audio_input=empty_audio(), thread=thread): + pass + + messages = await thread.message_store.list_messages() + assert len(messages) == 2 + assert messages[0].role == "user" + assert messages[0].text == "What is the weather?" + assert messages[1].role == "assistant" + assert messages[1].text == "It is sunny today." + + +@pytest.mark.asyncio +async def test_realtime_agent_creates_thread_when_none_provided(): + """Test that run() creates a new thread when none is given.""" + client = MockRealtimeClient( + events_to_yield=[ + RealtimeEvent(type="input_transcript", data={"text": "Hello"}), + RealtimeEvent(type="response_transcript", data={"text": "Hi there"}), + ] + ) + agent = RealtimeAgent(realtime_client=client) + + async def empty_audio(): + return + yield + + async for _ in agent.run(audio_input=empty_audio()): + pass + + +@pytest.mark.asyncio +async def test_realtime_agent_skips_empty_transcripts(): + """Test that empty transcripts are not stored in the thread.""" + client = MockRealtimeClient( + events_to_yield=[ + RealtimeEvent(type="input_transcript", data={"text": ""}), + RealtimeEvent(type="input_transcript", data={"text": "Hello"}), + RealtimeEvent(type="response_transcript", data={"text": ""}), + RealtimeEvent(type="response_transcript", data={"text": "Hi"}), + ] + ) + agent = RealtimeAgent(realtime_client=client) + thread = AgentThread() + + async def empty_audio(): + return + yield + + async for _ in agent.run(audio_input=empty_audio(), thread=thread): + pass + + messages = await thread.message_store.list_messages() + assert len(messages) == 2 + assert messages[0].text == "Hello" + assert messages[1].text == "Hi" + + +@pytest.mark.asyncio +async def test_realtime_agent_stores_multiple_turns(): + """Test that multiple conversation turns are stored in order.""" + client = MockRealtimeClient( + events_to_yield=[ + RealtimeEvent(type="input_transcript", data={"text": "What time is it?"}), + RealtimeEvent(type="response_transcript", data={"text": "It is 3 PM."}), + RealtimeEvent(type="input_transcript", data={"text": "Thanks!"}), + RealtimeEvent(type="response_transcript", data={"text": "You're welcome."}), + ] + ) + agent = RealtimeAgent(realtime_client=client) + thread = AgentThread() + + async def empty_audio(): + return + yield + + async for _ in agent.run(audio_input=empty_audio(), thread=thread): + pass + + messages = await thread.message_store.list_messages() + assert len(messages) == 4 + assert messages[0].role == "user" + assert messages[0].text == "What time is it?" + assert messages[1].role == "user" + assert messages[1].text == "Thanks!" + assert messages[2].role == "assistant" + assert messages[2].text == "It is 3 PM." + assert messages[3].role == "assistant" + assert messages[3].text == "You're welcome." diff --git a/python/packages/core/tests/core/test_realtime_client.py b/python/packages/core/tests/core/test_realtime_client.py new file mode 100644 index 0000000000..a1174778c7 --- /dev/null +++ b/python/packages/core/tests/core/test_realtime_client.py @@ -0,0 +1,198 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for RealtimeClientProtocol.""" + +from collections.abc import AsyncIterator + +import pytest + +from agent_framework._realtime_client import RealtimeClientProtocol +from agent_framework._realtime_types import RealtimeEvent, RealtimeSessionConfig + + +class MockRealtimeClient: + """Mock implementation for testing protocol compliance.""" + + def __init__(self): + self.additional_properties: dict = {} + + async def connect(self, config: RealtimeSessionConfig) -> None: + pass + + async def disconnect(self) -> None: + pass + + async def send_audio(self, audio: bytes) -> None: + pass + + async def send_tool_result(self, tool_call_id: str, result: str) -> None: + pass + + async def send_text(self, text: str) -> None: + pass + + async def update_session(self, config: RealtimeSessionConfig) -> None: + pass + + async def events(self) -> AsyncIterator[RealtimeEvent]: + yield RealtimeEvent(type="audio", data={}) + + +async def test_update_session_in_protocol(): + """Test that update_session is callable on protocol-implementing clients.""" + client = MockRealtimeClient() + assert isinstance(client, RealtimeClientProtocol) + # Should be callable with a RealtimeSessionConfig + config = RealtimeSessionConfig(instructions="Updated instructions") + await client.update_session(config) + + +def test_mock_client_implements_protocol(): + """Test that MockRealtimeClient satisfies RealtimeClientProtocol.""" + client = MockRealtimeClient() + assert isinstance(client, RealtimeClientProtocol) + + +@pytest.mark.asyncio +async def test_events_yields_realtime_events(): + """Test that events() yields RealtimeEvent objects.""" + client = MockRealtimeClient() + async for event in client.events(): + assert isinstance(event, RealtimeEvent) + break + + +def test_base_realtime_client_serialization(): + """Test that BaseRealtimeClient supports serialization.""" + from agent_framework._realtime_client import BaseRealtimeClient + from agent_framework._serialization import SerializationMixin + + assert issubclass(BaseRealtimeClient, SerializationMixin) + + +def test_realtime_client_has_additional_properties(): + """Test that realtime clients have additional_properties.""" + from agent_framework._realtime_client import BaseRealtimeClient + + assert hasattr(BaseRealtimeClient, "DEFAULT_EXCLUDE") + assert "additional_properties" in BaseRealtimeClient.DEFAULT_EXCLUDE + + +def test_realtime_client_as_agent(): + """Test BaseRealtimeClient.as_agent() creates a RealtimeAgent.""" + from agent_framework._realtime_agent import RealtimeAgent + from agent_framework._realtime_client import BaseRealtimeClient + + class ConcreteRealtimeClient(BaseRealtimeClient): + async def connect(self, config): + pass + + async def disconnect(self): + pass + + async def send_audio(self, audio): + pass + + async def send_tool_result(self, tool_call_id, result): + pass + + async def update_session(self, config): + pass + + async def events(self): + return + yield # Make it an async generator + + client = ConcreteRealtimeClient() + agent = client.as_agent( + name="test-agent", + instructions="Be helpful", + voice="nova", + ) + + assert isinstance(agent, RealtimeAgent) + assert agent.name == "test-agent" + assert agent.instructions == "Be helpful" + assert agent.voice == "nova" + + +def test_build_session_config_minimal(): + """Test _build_session_config with minimal config.""" + from agent_framework._realtime_client import BaseRealtimeClient + + class ConcreteClient(BaseRealtimeClient): + async def connect(self, config): + pass + + async def disconnect(self): + pass + + async def send_audio(self, audio): + pass + + async def send_tool_result(self, tool_call_id, result): + pass + + async def update_session(self, config): + pass + + async def events(self): + return + yield + + client = ConcreteClient() + config = RealtimeSessionConfig() + result = client._build_session_config(config) + + assert result["input_audio_format"] == "pcm16" + assert result["output_audio_format"] == "pcm16" + assert result["modalities"] == ["text", "audio"] + assert result["turn_detection"] == {"type": "server_vad"} + assert "voice" not in result + assert "instructions" not in result + assert "tools" not in result + + +def test_build_session_config_full(): + """Test _build_session_config with all fields populated.""" + from agent_framework._realtime_client import BaseRealtimeClient + + class ConcreteClient(BaseRealtimeClient): + async def connect(self, config): + pass + + async def disconnect(self): + pass + + async def send_audio(self, audio): + pass + + async def send_tool_result(self, tool_call_id, result): + pass + + async def update_session(self, config): + pass + + async def events(self): + return + yield + + client = ConcreteClient() + tools = [{"name": "get_weather", "description": "Get weather"}] + turn_detection = {"type": "server_vad", "threshold": 0.5} + config = RealtimeSessionConfig( + instructions="Be helpful", + voice="nova", + tools=tools, + input_audio_format="pcm16", + output_audio_format="pcm16", + turn_detection=turn_detection, + ) + result = client._build_session_config(config) + + assert result["instructions"] == "Be helpful" + assert result["voice"] == "nova" + assert result["tools"] == tools + assert result["turn_detection"] == turn_detection + assert result["input_audio_format"] == "pcm16" + assert result["output_audio_format"] == "pcm16" diff --git a/python/packages/core/tests/core/test_realtime_types.py b/python/packages/core/tests/core/test_realtime_types.py new file mode 100644 index 0000000000..b660a547ec --- /dev/null +++ b/python/packages/core/tests/core/test_realtime_types.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework._realtime_types import RealtimeEvent, RealtimeSessionConfig + + +def test_realtime_event_creation(): + """Test RealtimeEvent can be created with required fields.""" + event = RealtimeEvent(type="audio", data={"audio": b"test"}) + assert event.type == "audio" + assert event.data == {"audio": b"test"} + + +def test_realtime_event_types(): + """Test all expected event types can be created.""" + event_types = [ + "audio", + "transcript", + "tool_call", + "interrupted", + "error", + "session_update", + "listening", + "speaking_done", + ] + for event_type in event_types: + event = RealtimeEvent(type=event_type, data={}) + assert event.type == event_type + + +def test_realtime_session_config_defaults(): + """Test RealtimeSessionConfig has sensible defaults.""" + config = RealtimeSessionConfig() + assert config.instructions is None + assert config.voice is None + assert config.tools is None + assert config.input_audio_format is None + assert config.output_audio_format is None + assert config.turn_detection is None + + +def test_realtime_session_config_with_values(): + """Test RealtimeSessionConfig accepts all fields.""" + config = RealtimeSessionConfig( + instructions="You are helpful.", + voice="nova", + tools=[{"name": "test", "description": "test tool"}], + input_audio_format="pcm16", + output_audio_format="pcm16", + turn_detection={"type": "server_vad"}, + ) + assert config.instructions == "You are helpful." + assert config.voice == "nova" + assert len(config.tools) == 1 + + +def test_exports_from_main_module(): + """Test realtime types are exported from main agent_framework module.""" + from agent_framework import ( + BaseRealtimeClient, + RealtimeAgent, + RealtimeClientProtocol, + RealtimeEvent, + RealtimeSessionConfig, + ) + + # Verify the imports are the correct types + assert RealtimeEvent is not None + assert RealtimeSessionConfig is not None + assert RealtimeAgent is not None + assert RealtimeClientProtocol is not None + assert BaseRealtimeClient is not None + + +def test_openai_realtime_client_export(): + """Test OpenAIRealtimeClient is exported from openai module.""" + from agent_framework.openai import OpenAIRealtimeClient + + assert OpenAIRealtimeClient is not None + + +def test_azure_realtime_client_export(): + """Test AzureOpenAIRealtimeClient is exported from azure module.""" + from agent_framework.azure import AzureOpenAIRealtimeClient + + assert AzureOpenAIRealtimeClient is not None diff --git a/python/packages/core/tests/openai/test_openai_realtime_client.py b/python/packages/core/tests/openai/test_openai_realtime_client.py new file mode 100644 index 0000000000..21a088db5d --- /dev/null +++ b/python/packages/core/tests/openai/test_openai_realtime_client.py @@ -0,0 +1,249 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Tests for OpenAIRealtimeClient.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from agent_framework._realtime_client import BaseRealtimeClient, RealtimeClientProtocol +from agent_framework._realtime_types import RealtimeSessionConfig +from agent_framework.openai._realtime_client import OpenAIRealtimeClient +from agent_framework.openai._shared import OpenAIConfigMixin + + +def test_openai_realtime_client_implements_protocol(): + """Test OpenAIRealtimeClient satisfies RealtimeClientProtocol.""" + client = OpenAIRealtimeClient(model_id="gpt-4o-realtime-preview", api_key="test-key") + assert isinstance(client, RealtimeClientProtocol) + + +def test_openai_realtime_client_inherits_config_mixin(): + """Test OpenAIRealtimeClient inherits from OpenAIConfigMixin.""" + assert issubclass(OpenAIRealtimeClient, OpenAIConfigMixin) + assert issubclass(OpenAIRealtimeClient, BaseRealtimeClient) + + +def test_openai_realtime_client_default_model(): + """Test default model is set.""" + client = OpenAIRealtimeClient(api_key="test-key") + assert client.model_id == "gpt-4o-realtime-preview" + + +def test_openai_realtime_client_custom_model(): + """Test custom model can be set.""" + client = OpenAIRealtimeClient(api_key="test-key", model_id="gpt-4o-realtime-preview-2024-12-17") + assert client.model_id == "gpt-4o-realtime-preview-2024-12-17" + + +def test_openai_realtime_client_has_openai_client(): + """Test client creates an AsyncOpenAI instance via mixin.""" + client = OpenAIRealtimeClient(api_key="test-key") + assert client.client is not None + + +def test_openai_realtime_client_otel_provider_name(): + """Test OpenAIRealtimeClient has OTEL_PROVIDER_NAME.""" + assert OpenAIRealtimeClient.OTEL_PROVIDER_NAME == "openai" + + +def test_openai_realtime_client_has_additional_properties(): + """Test OpenAIRealtimeClient has additional_properties.""" + client = OpenAIRealtimeClient(api_key="test-key") + assert hasattr(client, "additional_properties") + assert isinstance(client.additional_properties, dict) + + +def test_openai_realtime_client_requires_api_key(): + """Test client requires api_key when no client provided.""" + from agent_framework.exceptions import ServiceInitializationError + + with pytest.raises(ServiceInitializationError): + OpenAIRealtimeClient(model_id="gpt-4o-realtime-preview") + + +def test_openai_realtime_client_accepts_existing_client(): + """Test client accepts a pre-built AsyncOpenAI instance.""" + from openai import AsyncOpenAI + + existing_client = AsyncOpenAI(api_key="test-key") + client = OpenAIRealtimeClient(model_id="gpt-4o-realtime-preview", client=existing_client) + assert client.client is existing_client + + +def test_openai_realtime_client_settings_fallback(monkeypatch): + """Test client falls back to OpenAISettings env vars.""" + monkeypatch.setenv("OPENAI_API_KEY", "env-key") + client = OpenAIRealtimeClient(model_id="gpt-4o-realtime-preview") + assert client.client is not None + + +@pytest.mark.asyncio +async def test_openai_realtime_client_connect(): + """Test connect uses SDK realtime.connect().""" + client = OpenAIRealtimeClient(api_key="test-key") + + mock_connection = AsyncMock() + mock_connection_manager = AsyncMock() + mock_connection_manager.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection_manager.__aexit__ = AsyncMock(return_value=False) + + with patch.object(client.client.realtime, "connect", return_value=mock_connection_manager) as mock_connect: + config = RealtimeSessionConfig(instructions="Be helpful", voice="nova") + await client.connect(config) + + mock_connect.assert_called_once_with(model="gpt-4o-realtime-preview") + mock_connection.session.update.assert_called_once() + + +@pytest.mark.asyncio +async def test_openai_realtime_client_disconnect(): + """Test disconnect closes SDK connection.""" + client = OpenAIRealtimeClient(api_key="test-key") + + mock_connection = AsyncMock() + client._connection = mock_connection + mock_connection_manager = AsyncMock() + mock_connection_manager.__aexit__ = AsyncMock(return_value=False) + client._connection_manager = mock_connection_manager + client._connected = True + + await client.disconnect() + + mock_connection_manager.__aexit__.assert_called_once() + + +@pytest.mark.asyncio +async def test_openai_realtime_client_send_audio(): + """Test send_audio sends base64-encoded audio via SDK.""" + client = OpenAIRealtimeClient(api_key="test-key") + + mock_connection = AsyncMock() + client._connection = mock_connection + client._connected = True + + await client.send_audio(b"\x00\x01\x02\x03") + + mock_connection.send.assert_called_once() + call_args = mock_connection.send.call_args + event = call_args[0][0] + assert event["type"] == "input_audio_buffer.append" + assert "audio" in event + + +@pytest.mark.asyncio +async def test_openai_realtime_client_send_tool_result(): + """Test send_tool_result sends function output via SDK.""" + client = OpenAIRealtimeClient(api_key="test-key") + + mock_connection = AsyncMock() + client._connection = mock_connection + client._connected = True + + await client.send_tool_result("call-123", "sunny, 72F") + + # Should send conversation.item.create then response.create + assert mock_connection.send.call_count == 2 + + +@pytest.mark.asyncio +async def test_openai_realtime_client_send_text(): + """Test send_text sends text input via SDK.""" + client = OpenAIRealtimeClient(api_key="test-key") + + mock_connection = AsyncMock() + client._connection = mock_connection + client._connected = True + + await client.send_text("Hello there") + + assert mock_connection.send.call_count == 2 + + +async def test_openai_realtime_update_session_not_connected(): + """Test update_session raises RuntimeError when not connected.""" + client = OpenAIRealtimeClient(api_key="test-key") + config = RealtimeSessionConfig(instructions="Updated instructions.") + + with pytest.raises(RuntimeError, match="Not connected"): + await client.update_session(config) + + +async def test_openai_realtime_update_session_sends_update(): + """Test update_session calls connection.session.update with GA-format config.""" + from unittest.mock import MagicMock + + mock_sdk_client = MagicMock() + mock_connection_manager = MagicMock() + mock_connection = AsyncMock() + + mock_connection_manager.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection_manager.__aexit__ = AsyncMock() + mock_sdk_client.realtime.connect.return_value = mock_connection_manager + + client = OpenAIRealtimeClient(api_key="test-key", client=mock_sdk_client) + + # Connect first + connect_config = RealtimeSessionConfig(instructions="Initial.", voice="nova") + await client.connect(connect_config) + + # Reset mock to isolate update_session call + mock_connection.session.update.reset_mock() + + # Update session with new config + update_config = RealtimeSessionConfig(instructions="Updated instructions.", voice="alloy") + await client.update_session(update_config) + + mock_connection.session.update.assert_called_once() + call_kwargs = mock_connection.session.update.call_args.kwargs + session = call_kwargs["session"] + # Verify GA API format + assert session["type"] == "realtime" + assert session["output_modalities"] == ["audio"] + assert "audio" in session + assert "input" in session["audio"] + assert "output" in session["audio"] + assert session["audio"]["output"]["voice"] == "alloy" + assert session["instructions"] == "Updated instructions." + + +def test_openai_build_session_config_minimal(): + """Test OpenAI _build_session_config produces GA format with minimal config.""" + client = OpenAIRealtimeClient(api_key="test-key") + config = RealtimeSessionConfig() + result = client._build_session_config(config) + + assert result["type"] == "realtime" + assert result["output_modalities"] == ["audio"] + assert result["audio"]["input"]["format"] == {"type": "audio/pcm", "rate": 24000} + assert result["audio"]["input"]["transcription"] == {"model": "whisper-1"} + assert result["audio"]["input"]["turn_detection"] == {"type": "server_vad"} + assert result["audio"]["output"]["format"] == {"type": "audio/pcm", "rate": 24000} + assert "voice" not in result["audio"]["output"] + assert "instructions" not in result + assert "tools" not in result + + +def test_openai_build_session_config_full(): + """Test OpenAI _build_session_config produces GA format with all fields.""" + client = OpenAIRealtimeClient(api_key="test-key") + tools = [{"type": "function", "name": "get_weather", "description": "Get weather"}] + turn_detection = {"type": "server_vad", "threshold": 0.5} + config = RealtimeSessionConfig( + instructions="Be helpful", + voice="nova", + tools=tools, + input_audio_format="pcm16", + output_audio_format="pcm16", + turn_detection=turn_detection, + ) + result = client._build_session_config(config) + + assert result["type"] == "realtime" + assert result["output_modalities"] == ["audio"] + assert result["audio"]["input"]["format"] == {"type": "audio/pcm", "rate": 24000} + assert result["audio"]["input"]["transcription"] == {"model": "whisper-1"} + assert result["audio"]["input"]["turn_detection"] == turn_detection + assert result["audio"]["output"]["format"] == {"type": "audio/pcm", "rate": 24000} + assert result["audio"]["output"]["voice"] == "nova" + assert result["instructions"] == "Be helpful" + assert result["tools"] == tools diff --git a/python/pyproject.toml b/python/pyproject.toml index 5c4fdd1788..af5c6b31e2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -79,6 +79,7 @@ agent-framework-azure-ai-search = { workspace = true } agent-framework-anthropic = { workspace = true } agent-framework-azure-ai = { workspace = true } agent-framework-azurefunctions = { workspace = true } +agent-framework-azure-voice-live = { workspace = true } agent-framework-bedrock = { workspace = true } agent-framework-chatkit = { workspace = true } agent-framework-copilotstudio = { workspace = true } diff --git a/python/samples/getting_started/realtime/README.md b/python/samples/getting_started/realtime/README.md new file mode 100644 index 0000000000..253e8e8d21 --- /dev/null +++ b/python/samples/getting_started/realtime/README.md @@ -0,0 +1,137 @@ +# Realtime Voice Agent Samples + +These samples demonstrate how to use the Agent Framework's realtime voice capabilities for bidirectional audio streaming with LLM providers. + +## Overview + +The realtime voice agents enable natural voice conversations with AI models through WebSocket connections. Key features include: + +- **Bidirectional audio streaming**: Send and receive audio in real-time +- **Voice Activity Detection (VAD)**: Automatic detection of when the user starts/stops speaking +- **Function calling**: Tools work seamlessly during voice conversations +- **Multiple providers**: Support for OpenAI Realtime, Azure OpenAI Realtime, and Azure Voice Live + +## Prerequisites + +### Required Dependencies + +```bash +pip install agent-framework-core websockets +``` + +### For Microphone Samples + +```bash +pip install pyaudio +``` + +### For FastAPI WebSocket Sample + +```bash +pip install fastapi uvicorn +``` + +On macOS, you may need to install PortAudio first: +```bash +brew install portaudio +``` + +### Environment Variables + +**OpenAI:** +```bash +export OPENAI_API_KEY="your-openai-api-key" +``` + +**Azure OpenAI:** +```bash +export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com" +export AZURE_OPENAI_API_KEY="your-api-key" +export AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME="gpt-realtime" +``` + +**Azure Voice Live:** +```bash +export AZURE_VOICELIVE_ENDPOINT="https://your-resource.services.ai.azure.com" +export AZURE_VOICELIVE_API_KEY="your-api-key" +export AZURE_VOICELIVE_MODEL="gpt-realtime" +``` + +## Samples + +| Sample | Description | +|--------|-------------| +| `realtime_with_microphone.py` | Voice conversation using your microphone and speakers | +| `realtime_with_tools.py` | Voice conversation with function calling (weather, time, math) | +| `realtime_with_multiple_agents.py` | Multiple agents with transfer via `update_session()` — single connection reused across agents | +| `realtime_fastapi_websocket.py` | WebSocket API server for web browser clients | +| `websocket_audio_client.py` | CLI client that connects to the FastAPI WebSocket endpoint with microphone/speaker | +| `audio_utils.py` | Shared audio utilities (microphone capture, speaker playback) | + +## Configuring the Client Type + +All samples support three realtime providers. Configure via: + +**CLI argument** (microphone and tools samples): +```bash +python realtime_with_microphone.py --client-type azure_openai +python realtime_with_tools.py --client-type azure_voice_live +``` + +**Environment variable** (all samples): +```bash +export REALTIME_CLIENT_TYPE="openai" # or "azure_openai" or "azure_voice_live" +``` + +**FastAPI sample** (env var only, since it runs via uvicorn): +```bash +REALTIME_CLIENT_TYPE=azure_openai uvicorn realtime_fastapi_websocket:app --reload +``` + +The default client type is `"openai"`. Each client reads its required credentials from environment variables automatically. + +## Audio Format + +All realtime clients use PCM16 audio format by default: +- **Sample rate**: 24kHz (OpenAI/Azure OpenAI) or configurable (Voice Live) +- **Channels**: Mono (1 channel) +- **Bit depth**: 16-bit signed integers + +## Event Types + +The `RealtimeEvent` class normalizes events across providers: + +| Event Type | Description | +|------------|-------------| +| `audio` | Audio chunk from the model | +| `transcript` | Text transcript of model's speech | +| `input_transcript` | Transcript of user's speech input | +| `tool_call` | Function call request | +| `tool_result` | Result of a function call execution | +| `listening` | VAD detected user speech started | +| `speaking_done` | Model finished speaking | +| `session_update` | Session created or configuration updated (e.g. via `update_session()`) | +| `error` | Error occurred | + +## Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Audio Input │────▶│ RealtimeAgent │────▶│ Audio Output │ +│ (Microphone) │ │ │ │ (Speaker) │ +└─────────────────┘ │ ┌────────────┐ │ └─────────────────┘ + │ │ Tools │ │ + │ └────────────┘ │ + │ │ + │ ┌────────────┐ │ + │ │ Client │ │ + │ │ (WebSocket)│ │ + │ └────────────┘ │ + └──────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ LLM Provider │ + │ (OpenAI/Azure) │ + └──────────────────┘ +``` diff --git a/python/samples/getting_started/realtime/__init__.py b/python/samples/getting_started/realtime/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/samples/getting_started/realtime/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/samples/getting_started/realtime/audio_utils.py b/python/samples/getting_started/realtime/audio_utils.py new file mode 100644 index 0000000000..b67503ef43 --- /dev/null +++ b/python/samples/getting_started/realtime/audio_utils.py @@ -0,0 +1,151 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Audio utilities for realtime voice samples. + +Provides microphone capture and speaker playback for use with RealtimeAgent. + +Requirements: +- pyaudio package: pip install pyaudio +- On macOS: brew install portaudio (before pip install pyaudio) +""" + +import asyncio +import queue +import threading +from collections.abc import AsyncIterator + +# Audio configuration matching OpenAI Realtime API requirements +SAMPLE_RATE = 24000 # 24kHz +CHANNELS = 1 # Mono +CHUNK_SIZE = 2400 # 100ms at 24kHz (24000 * 0.1) +FORMAT_BYTES = 2 # 16-bit = 2 bytes per sample + + +class AudioPlayer: + """Plays audio chunks through the default speaker.""" + + def __init__(self): + self._queue: queue.Queue[bytes] = queue.Queue() + self._running = False + self._thread: threading.Thread | None = None + self._pyaudio = None + self._stream = None + + def start(self) -> None: + """Start the audio playback thread.""" + import pyaudio + + self._pyaudio = pyaudio.PyAudio() + self._stream = self._pyaudio.open( + format=pyaudio.paInt16, + channels=CHANNELS, + rate=SAMPLE_RATE, + output=True, + frames_per_buffer=CHUNK_SIZE, + ) + self._running = True + self._thread = threading.Thread(target=self._play_loop, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the audio playback.""" + self._running = False + if self._thread: + self._thread.join(timeout=1.0) + if self._stream: + self._stream.stop_stream() + self._stream.close() + if self._pyaudio: + self._pyaudio.terminate() + + def play(self, audio: bytes) -> None: + """Queue audio for playback.""" + self._queue.put(audio) + + def clear(self) -> None: + """Clear queued audio (for interruptions).""" + while not self._queue.empty(): + try: + self._queue.get_nowait() + except queue.Empty: + break + + def _play_loop(self) -> None: + """Background thread that plays queued audio.""" + while self._running: + try: + audio = self._queue.get(timeout=0.1) + if self._stream and audio: + self._stream.write(audio) + except queue.Empty: + continue + + +class MicrophoneCapture: + """Captures audio from the default microphone.""" + + def __init__(self): + self._queue: queue.Queue[bytes] = queue.Queue() + self._running = False + self._pyaudio = None + self._stream = None + + def start(self) -> None: + """Start capturing from the microphone.""" + import pyaudio + + self._pyaudio = pyaudio.PyAudio() + self._running = True + self._stream = self._pyaudio.open( + format=pyaudio.paInt16, + channels=CHANNELS, + rate=SAMPLE_RATE, + input=True, + frames_per_buffer=CHUNK_SIZE, + stream_callback=self._callback, + ) + self._stream.start_stream() + + def stop(self) -> None: + """Stop capturing.""" + self._running = False + if self._stream: + self._stream.stop_stream() + self._stream.close() + if self._pyaudio: + self._pyaudio.terminate() + + def _callback(self, in_data, frame_count, time_info, status): + """PyAudio callback - called when audio is available.""" + import pyaudio + + if self._running and in_data: + self._queue.put(in_data) + return (None, pyaudio.paContinue) + + async def audio_generator(self) -> AsyncIterator[bytes]: + """Async generator that yields audio chunks.""" + while self._running: + try: + # Non-blocking get with short timeout + audio = self._queue.get(timeout=0.05) + yield audio + except queue.Empty: + await asyncio.sleep(0.01) + + +def check_pyaudio() -> bool: + """Check if pyaudio is available, print help if not. + + Returns: + True if pyaudio is available, False otherwise. + """ + try: + import pyaudio # noqa: F401 + + return True + except ImportError: + print("Error: pyaudio not installed") + print("Install with: pip install pyaudio") + print("On macOS, first run: brew install portaudio") + return False diff --git a/python/samples/getting_started/realtime/realtime_fastapi_websocket.py b/python/samples/getting_started/realtime/realtime_fastapi_websocket.py new file mode 100644 index 0000000000..da4bdce283 --- /dev/null +++ b/python/samples/getting_started/realtime/realtime_fastapi_websocket.py @@ -0,0 +1,366 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import base64 +import contextlib +import json +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Annotated + +from agent_framework import BaseRealtimeClient, RealtimeAgent, tool +from agent_framework.azure import AzureOpenAIRealtimeClient, AzureVoiceLiveClient +from agent_framework.openai import OpenAIRealtimeClient +from azure.identity import DefaultAzureCredential +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from pydantic import Field + +""" +Realtime Voice Agent with FastAPI WebSocket + +This sample demonstrates how to expose a realtime voice agent through a FastAPI +WebSocket endpoint, enabling web browsers and other clients to have voice +conversations with the AI. + +Supported client types (set REALTIME_CLIENT_TYPE env var, default: "openai"): +- "openai" — OpenAI Realtime API (requires OPENAI_API_KEY) +- "azure_openai" — Azure OpenAI Realtime API (requires AZURE_OPENAI_ENDPOINT, etc.) +- "azure_voice_live" — Azure Voice Live API (reads config from env) + +Requirements: +- pip install fastapi uvicorn websockets + +Run with: + REALTIME_CLIENT_TYPE=openai uvicorn realtime_fastapi_websocket:app --reload + +Then connect via WebSocket at: ws://localhost:8000/ws/voice + +The sample shows: +1. Setting up a FastAPI WebSocket endpoint +2. Bridging client audio to RealtimeAgent +3. Streaming AI audio responses back to the client +4. Handling multiple concurrent sessions +""" + +# ============================================================================ +# Tools available to the voice agent +# ============================================================================ + + +@tool +def get_weather( + location: Annotated[str, Field(description="The city to get weather for")], +) -> str: + """Get the current weather for a location.""" + # Mock implementation + import random + + conditions = ["sunny", "cloudy", "rainy", "partly cloudy"] + temp = random.randint(15, 30) + return f"The weather in {location} is {conditions[random.randint(0, 3)]} with {temp}°C." + + +@tool +def get_time() -> str: + """Get the current time.""" + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + return f"The current UTC time is {now.strftime('%I:%M %p')}." + + +# ============================================================================ +# Client factory +# ============================================================================ + + +def create_realtime_client(client_type: str) -> BaseRealtimeClient: + """Create a realtime client based on the specified type.""" + if client_type == "openai": + return OpenAIRealtimeClient( + model_id="gpt-realtime" + ) + if client_type == "azure_openai": + return AzureOpenAIRealtimeClient( + deployment_name="gpt-realtime", + credential=DefaultAzureCredential() + ) + if client_type == "azure_voice_live": + return AzureVoiceLiveClient( + credential=DefaultAzureCredential() + ) + raise ValueError(f"Unknown client type: {client_type}. Valid values: openai, azure_openai, azure_voice_live") + + +# ============================================================================ +# WebSocket Voice Session Handler +# ============================================================================ + + +class VoiceSession: + """Manages a single voice conversation session over WebSocket.""" + + def __init__(self, websocket: WebSocket, agent: RealtimeAgent): + self.websocket = websocket + self.agent = agent + self._audio_queue: asyncio.Queue[bytes] = asyncio.Queue() + self._running = False + + async def audio_input_generator(self) -> AsyncIterator[bytes]: + """Yields audio chunks received from the WebSocket client.""" + while self._running: + try: + audio = await asyncio.wait_for(self._audio_queue.get(), timeout=0.1) + yield audio + except asyncio.TimeoutError: + continue + + async def handle_client_message(self, data: str) -> None: + """Process a message received from the WebSocket client.""" + try: + message = json.loads(data) + msg_type = message.get("type", "") + + if msg_type == "audio": + # Client sent audio data (base64 encoded) + audio_b64 = message.get("audio", "") + if audio_b64: + audio_bytes = base64.b64decode(audio_b64) + await self._audio_queue.put(audio_bytes) + + elif msg_type == "text": + # Client sent text (for testing without microphone) + # This could trigger a text-to-speech flow + text = message.get("text", "") + await self.send_event("info", {"message": f"Received text: {text}"}) + + except json.JSONDecodeError: + await self.send_event("error", {"message": "Invalid JSON"}) + + async def send_event(self, event_type: str, data: dict) -> None: + """Send an event to the WebSocket client.""" + await self.websocket.send_json({"type": event_type, **data}) + + async def run(self) -> None: + """Run the voice session, processing events from the RealtimeAgent.""" + self._running = True + + # Start receiving client messages in background + receive_task = asyncio.create_task(self._receive_loop()) + + try: + # Run the agent and forward events to client + async for event in self.agent.run(audio_input=self.audio_input_generator()): + await self._handle_agent_event(event) + + except Exception as e: + await self.send_event("error", {"message": str(e)}) + finally: + self._running = False + receive_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await receive_task + + async def _receive_loop(self) -> None: + """Background task to receive messages from the client.""" + try: + while self._running: + data = await self.websocket.receive_text() + await self.handle_client_message(data) + except WebSocketDisconnect: + self._running = False + except asyncio.CancelledError: + pass + + async def _handle_agent_event(self, event) -> None: + """Forward RealtimeAgent events to the WebSocket client.""" + if event.type == "audio": + # Send audio as base64 + audio_bytes = event.data.get("audio", b"") + if audio_bytes: + await self.send_event("audio", { + "audio": base64.b64encode(audio_bytes).decode("utf-8") + }) + + elif event.type == "transcript": + text = event.data.get("text", "") + if text: + await self.send_event("transcript", {"text": text}) + + elif event.type == "listening": + await self.send_event("listening", {}) + + elif event.type == "speaking_done": + await self.send_event("speaking_done", {}) + + elif event.type == "tool_call": + await self.send_event("tool_call", { + "name": event.data.get("name", ""), + "arguments": event.data.get("arguments", "{}"), + }) + + elif event.type == "tool_result": + await self.send_event("tool_result", { + "name": event.data.get("name", ""), + "result": event.data.get("result", ""), + }) + + elif event.type == "input_transcript": + text = event.data.get("text", "") + if text: + await self.send_event("input_transcript", {"text": text}) + + elif event.type == "error": + await self.send_event("error", {"details": event.data.get("error", {})}) + + elif event.type == "session_update": + await self.send_event("session_update", { + "status": event.data.get("raw_type", "updated") + }) + + +# ============================================================================ +# FastAPI Application +# ============================================================================ + + +client_type = os.environ.get("REALTIME_CLIENT_TYPE", "openai") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler.""" + # Startup + print(f"Realtime client type: {client_type}") + print("Voice WebSocket endpoint available at: ws://localhost:8000/ws/voice") + yield + # Shutdown + print("Shutting down...") + + +app = FastAPI( + title="Realtime Voice Agent API", + description="WebSocket API for real-time voice conversations with AI", + version="1.0.0", + lifespan=lifespan, +) + + +@app.websocket("/ws/voice") +async def websocket_voice_endpoint(websocket: WebSocket): + """ + WebSocket endpoint for real-time voice conversations. + + Client Protocol: + - Send: {"type": "audio", "audio": ""} + - Receive: {"type": "audio", "audio": ""} + - Receive: {"type": "transcript", "text": "..."} + - Receive: {"type": "listening"} + - Receive: {"type": "speaking_done"} + - Receive: {"type": "tool_call", "name": "...", "arguments": "..."} + - Receive: {"type": "error", "message": "..."} + """ + await websocket.accept() + + # Create realtime client and agent for this session + client = create_realtime_client(client_type) + + agent = RealtimeAgent( + realtime_client=client, + name="WebVoiceAssistant", + instructions="""You are a helpful voice assistant accessible via web browser. + Keep responses concise and conversational. You can check weather and time.""", + voice="alloy", + tools=[get_weather, get_time], + ) + + # Run the voice session + session = VoiceSession(websocket, agent) + + try: + await websocket.send_json({ + "type": "session_update", + "status": "connecting", + }) + await session.run() + except WebSocketDisconnect: + print("Client disconnected") + except Exception as e: + print(f"Session error: {e}") + with contextlib.suppress(Exception): + await websocket.send_json({"type": "error", "message": str(e)}) + finally: + with contextlib.suppress(Exception): + await websocket.close() + + +# ============================================================================ +# Run with: uvicorn realtime_fastapi_websocket:app --reload +# ============================================================================ + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) + + +""" +Sample Client (JavaScript): + +```javascript +const ws = new WebSocket('ws://localhost:8000/ws/voice'); + +// Handle incoming messages +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + + switch(msg.type) { + case 'audio': + // Decode and play audio + const audioData = atob(msg.audio); + playAudio(audioData); + break; + case 'transcript': + console.log('AI:', msg.text); + break; + case 'listening': + console.log('Listening to you...'); + break; + case 'speaking_done': + console.log('AI finished speaking'); + break; + case 'tool_call': + console.log('Tool called:', msg.name); + break; + case 'error': + console.error('Error:', msg.message); + break; + } +}; + +// Send audio from microphone +function sendAudio(pcm16Bytes) { + const base64 = btoa(String.fromCharCode(...pcm16Bytes)); + ws.send(JSON.stringify({ + type: 'audio', + audio: base64 + })); +} + +// Capture microphone and stream +navigator.mediaDevices.getUserMedia({ audio: true }) + .then(stream => { + // Process audio and send via sendAudio() + // Note: Browser audio needs to be converted to PCM16 24kHz + }); +``` + +Sample Output (Server): + +$ REALTIME_CLIENT_TYPE=openai uvicorn realtime_fastapi_websocket:app --reload +INFO: Uvicorn running on http://0.0.0.0:8000 +Realtime client type: openai +Voice WebSocket endpoint available at: ws://localhost:8000/ws/voice +INFO: Application startup complete. +""" diff --git a/python/samples/getting_started/realtime/realtime_with_microphone.py b/python/samples/getting_started/realtime/realtime_with_microphone.py new file mode 100644 index 0000000000..5d3d2fc5bd --- /dev/null +++ b/python/samples/getting_started/realtime/realtime_with_microphone.py @@ -0,0 +1,195 @@ +# Copyright (c) Microsoft. All rights reserved. + +import argparse +import asyncio +import os + +from agent_framework import BaseRealtimeClient, RealtimeAgent +from agent_framework.azure import AzureOpenAIRealtimeClient, AzureVoiceLiveClient +from agent_framework.openai import OpenAIRealtimeClient +from audio_utils import AudioPlayer, MicrophoneCapture, check_pyaudio +from azure.identity import DefaultAzureCredential + +""" +Realtime Voice Agent with Microphone Example + +This sample demonstrates a full voice conversation using your microphone and speakers. +It captures audio from your microphone, streams it to the realtime API, and plays +the response audio through your speakers. + +Supported client types (set via --client-type or REALTIME_CLIENT_TYPE env var): +- openai: OpenAI Realtime API (requires OPENAI_API_KEY) +- azure_openai: Azure OpenAI Realtime API (requires AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY) +- azure_voice_live: Azure Voice Live API (requires AZURE_VOICELIVE_ENDPOINT, AZURE_VOICELIVE_MODEL, AZURE_VOICELIVE_API_KEY) + +Requirements: +- Environment variables set for the chosen client type +- pyaudio package: pip install pyaudio +- On macOS: brew install portaudio (before pip install pyaudio) + +The sample shows: +1. Capturing microphone audio in real-time +2. Streaming audio to the RealtimeAgent +3. Playing response audio through speakers +4. Handling interruptions (barge-in) +5. Configuring the realtime client type via CLI or environment variable +""" + + +def create_realtime_client(client_type: str) -> BaseRealtimeClient: + """Create a realtime client based on the specified type.""" + if client_type == "openai": + return OpenAIRealtimeClient(model_id="gpt-realtime") + if client_type == "azure_openai": + return AzureOpenAIRealtimeClient( + deployment_name="gpt-realtime", + credential=DefaultAzureCredential(), + ) + if client_type == "azure_voice_live": + return AzureVoiceLiveClient( + credential=DefaultAzureCredential(), + ) + raise ValueError(f"Unknown client type: {client_type}. Valid values: openai, azure_openai, azure_voice_live") + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description="Realtime Voice Agent with Microphone") + parser.add_argument( + "--client-type", + type=str, + default=os.environ.get("REALTIME_CLIENT_TYPE", "openai"), + choices=["openai", "azure_openai", "azure_voice_live"], + help="The realtime client type to use (default: openai, env: REALTIME_CLIENT_TYPE)", + ) + return parser.parse_args() + + +async def main(client_type: str) -> None: + """Run a voice conversation with microphone input.""" + print("=== Realtime Voice Chat with Microphone ===\n") + + if not check_pyaudio(): + return + + # 1. Create realtime client and agent + print(f"Using client type: {client_type}") + client = create_realtime_client(client_type) + + agent = RealtimeAgent( + realtime_client=client, + name="VoiceChat", + instructions="""You are a friendly voice assistant having a natural conversation. + Listen carefully and respond conversationally. Keep responses concise. + If the user interrupts you, stop speaking and listen to them.""", + voice="alloy", + ) + + # 2. Set up audio I/O + microphone = MicrophoneCapture() + player = AudioPlayer() + + print("Starting audio devices...") + microphone.start() + player.start() + + print("\n" + "=" * 50) + print("Voice chat is now active!") + print("Speak into your microphone to talk with the AI.") + print("Press Ctrl+C to end the conversation.") + print("=" * 50 + "\n") + + # 3. Run the conversation + ai_speaking = False + try: + async for event in agent.run(audio_input=microphone.audio_generator()): + if event.type == "session_update": + if "created" in event.data.get("raw_type", ""): + print("[Connected to realtime API]") + + elif event.type == "listening": + print("\n[You are speaking...]") + ai_speaking = False + # Clear any queued audio when user starts speaking (barge-in) + player.clear() + + elif event.type == "audio": + # Play the AI's audio response + audio = event.data.get("audio", b"") + if audio: + player.play(audio) + + elif event.type == "transcript": + text = event.data.get("text", "") + if text: + # Print "AI: " prefix only for the first delta of each response + if not ai_speaking: + print("AI: ", end="", flush=True) + ai_speaking = True + print(text, end="", flush=True) + + elif event.type == "speaking_done": + print() # New line after AI finishes + ai_speaking = False + + elif event.type == "interrupted": + print("\n[Interrupted - listening...]") + ai_speaking = False + player.clear() + + elif event.type == "input_transcript": + text = event.data.get("text", "") + if text: + print(f"You: {text}") + + elif event.type == "error": + error = event.data.get("error", {}) + print(f"\n[Error: {error}]") + + except KeyboardInterrupt: + print("\n\n[Ending conversation...]") + except Exception as e: + print(f"\n[Error: {e}]") + finally: + # 4. Clean up + microphone.stop() + player.stop() + print("[Audio devices stopped]") + print("\nGoodbye!") + + +if __name__ == "__main__": + args = parse_args() + asyncio.run(main(client_type=args.client_type)) + + +""" +Sample Output: +=== Realtime Voice Chat with Microphone === + +Using client type: openai +Starting audio devices... + +================================================== +Voice chat is now active! +Speak into your microphone to talk with the AI. +Press Ctrl+C to end the conversation. +================================================== + +[Connected to realtime API] + +[You are speaking...] +You: Hello, how are you? +AI: Hello! I'm doing great, thank you for asking. How can I help you today? + +[You are speaking...] +You: What's the weather like? +AI: I'd be happy to help you with that. What location would you like to know about? + +^C + +[Ending conversation...] +[Audio devices stopped] + +Goodbye! +""" diff --git a/python/samples/getting_started/realtime/realtime_with_multiple_agents.py b/python/samples/getting_started/realtime/realtime_with_multiple_agents.py new file mode 100644 index 0000000000..1b486b20ff --- /dev/null +++ b/python/samples/getting_started/realtime/realtime_with_multiple_agents.py @@ -0,0 +1,476 @@ +# Copyright (c) Microsoft. All rights reserved. + +import argparse +import asyncio +import contextlib +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from random import randint +from typing import Annotated + +from agent_framework import BaseRealtimeClient, FunctionTool, RealtimeSessionConfig, execute_tool, tool, tool_to_schema +from agent_framework.azure import AzureOpenAIRealtimeClient, AzureVoiceLiveClient +from agent_framework.openai import OpenAIRealtimeClient +from audio_utils import AudioPlayer, MicrophoneCapture, check_pyaudio +from azure.identity import DefaultAzureCredential +from pydantic import Field + +""" +Realtime Voice Agent with Multiple Agents Example + +This sample demonstrates switching between multiple realtime voice agents during +a conversation. Each agent has its own instructions, tools, and personality. +A transfer tool triggers the switch between agents. + +Supported client types (set via --client-type or REALTIME_CLIENT_TYPE env var): +- openai: OpenAI Realtime API (requires OPENAI_API_KEY) +- azure_openai: Azure OpenAI Realtime API (requires AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY) +- azure_voice_live: Azure Voice Live API (requires AZURE_VOICELIVE_ENDPOINT, AZURE_VOICELIVE_MODEL, AZURE_VOICELIVE_API_KEY) + +Requirements: +- Environment variables set for the chosen client type +- pyaudio package: pip install pyaudio +- On macOS: brew install portaudio (before pip install pyaudio) + +The sample shows: +1. Defining multiple agents with different capabilities +2. Switching between agents mid-conversation using a transfer tool +3. Each agent maintaining its own instructions and tools +4. Reusing a single connection via update_session() — conversation context preserved server-side +""" + +# ============================================================================ +# Shared tools +# ============================================================================ + + +@tool +def get_weather( + location: Annotated[str, Field(description="The city to get weather for")], +) -> str: + """Get the current weather for a location.""" + conditions = ["sunny", "cloudy", "rainy", "partly cloudy"] + temp = randint(15, 30) + condition = conditions[randint(0, len(conditions) - 1)] + return f"The weather in {location} is {condition} with a temperature of {temp}°C." + + +@tool +def get_time( + timezone_name: Annotated[str, Field(description="Timezone like 'UTC' or 'America/New_York'")] = "UTC", +) -> str: + """Get the current time in a specified timezone.""" + current_time = datetime.now(timezone.utc) + return f"The current time in {timezone_name} is {current_time.strftime('%I:%M %p')}." + + +@tool +def calculate( + expression: Annotated[str, Field(description="A math expression like '2 + 2' or '10 * 5'")], +) -> str: + """Evaluate a simple math expression.""" + try: + allowed_chars = set("0123456789+-*/(). ") + if all(c in allowed_chars for c in expression): + result = eval(expression) + return f"The result of {expression} is {result}" + return "Invalid expression" + except Exception: + return "Could not evaluate expression" + + +@tool +def lookup_order( + order_id: Annotated[str, Field(description="The order ID to look up, e.g. 'ORD-1234'")], +) -> str: + """Look up an order by its ID.""" + # Mock order data + orders = { + "ORD-1001": "Laptop - Shipped, arriving tomorrow", + "ORD-1002": "Headphones - Delivered on Jan 15", + "ORD-1003": "Keyboard - Processing, ships in 2 days", + } + return orders.get(order_id, f"No order found with ID {order_id}") + + +@tool +def check_return_eligibility( + order_id: Annotated[str, Field(description="The order ID to check return eligibility for")], +) -> str: + """Check if an order is eligible for return.""" + return f"Order {order_id} is eligible for return within the next 14 days. Would you like to start a return?" + + +# ============================================================================ +# Agent definitions +# ============================================================================ + +@dataclass +class AgentDefinition: + """Defines a named agent configuration.""" + + name: str + display_name: str + instructions: str + tools: list + voice: str + + +# The transfer tool is created per-agent-set since it needs the agent registry. +# We define a placeholder here and build it in main(). +AGENT_DEFINITIONS = { + "greeter": AgentDefinition( + name="greeter", + display_name="Greeter", + instructions=( + "You are a friendly receptionist named Alex. Your job is to greet users warmly " + "and help direct them to the right agent. Keep your responses brief and welcoming.\n\n" + "If the user needs help with an order, return, or support issue, transfer to 'support'.\n" + "If the user asks about the weather, time, math, or general knowledge, transfer to 'assistant'.\n" + "For general chat, you can handle it yourself." + ), + tools=[], + voice="ash", + ), + "support": AgentDefinition( + name="support", + display_name="Support Agent", + instructions=( + "You are a customer support agent named Sam. You help users with orders, returns, " + "and other support questions. Be professional, empathetic, and solution-oriented.\n\n" + "You can look up orders and check return eligibility using your tools.\n" + "If the user wants to go back to general conversation, transfer to 'greeter'.\n" + "If they ask about weather, time, or math, transfer to 'assistant'." + ), + tools=[lookup_order, check_return_eligibility], + voice="alloy", + ), + "assistant": AgentDefinition( + name="assistant", + display_name="Assistant", + instructions=( + "You are a helpful assistant named Robin. You can check the weather, " + "tell the time, and do calculations. Be conversational and concise.\n\n" + "If the user needs help with an order or support issue, transfer to 'support'.\n" + "If they want general conversation, transfer to 'greeter'." + ), + tools=[get_weather, get_time, calculate], + voice="echo", + ), +} + + +# ============================================================================ +# Client factory +# ============================================================================ + + +def create_realtime_client(client_type: str) -> BaseRealtimeClient: + """Create a realtime client based on the specified type.""" + if client_type == "openai": + return OpenAIRealtimeClient(model_id="gpt-realtime") + if client_type == "azure_openai": + return AzureOpenAIRealtimeClient( + deployment_name="gpt-realtime", + credential=DefaultAzureCredential(), + ) + if client_type == "azure_voice_live": + return AzureVoiceLiveClient( + credential=DefaultAzureCredential(), + ) + raise ValueError(f"Unknown client type: {client_type}. Valid values: openai, azure_openai, azure_voice_live") + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description="Realtime Voice Agent — Multiple Agents") + parser.add_argument( + "--client-type", + type=str, + default=os.environ.get("REALTIME_CLIENT_TYPE", "openai"), + choices=["openai", "azure_openai", "azure_voice_live"], + help="The realtime client type to use (default: openai, env: REALTIME_CLIENT_TYPE)", + ) + return parser.parse_args() + + +# ============================================================================ +# Multi-agent conversation loop +# ============================================================================ + + +async def run_agent_session( + agent_def: AgentDefinition, + client: BaseRealtimeClient, + microphone: MicrophoneCapture, + player: AudioPlayer, + is_first: bool, +) -> str | None: + """Run a single agent session on an existing connection. + + On the first call, connects the client. On subsequent calls, updates the + session configuration so that the server-side conversation state is preserved. + + Returns the agent name to transfer to, or None to exit. + """ + transfer_target: str | None = None + + @tool + def transfer( + agent_name: Annotated[str, Field(description="Name of the agent to transfer to: 'greeter', 'support', or 'assistant'")], + ) -> str: + """Transfer the conversation to another agent when the user's request is better handled elsewhere. + + Available agents: + - 'greeter': General conversation and routing + - 'support': Order lookups, returns, and support issues + - 'assistant': Weather, time, calculations, and general knowledge + + Always tell the user you're transferring them before calling this tool. + """ + nonlocal transfer_target + if agent_name not in AGENT_DEFINITIONS: + return f"Unknown agent '{agent_name}'. Available: greeter, support, assistant" + transfer_target = agent_name + return f"Transferring to {AGENT_DEFINITIONS[agent_name].display_name}..." + + tools: list[FunctionTool] = list(agent_def.tools) + [transfer] + tool_registry = {tool.name: tool for tool in tools} + + # OpenAI and Azure OpenAI reject voice changes once assistant audio + # exists in the conversation. Voice Live supports it, so only include + # the voice when connecting for the first time or when using Voice Live. + supports_voice_change = isinstance(client, AzureVoiceLiveClient) + voice = agent_def.voice if (is_first or supports_voice_change) else None + + config = RealtimeSessionConfig( + instructions=agent_def.instructions, + voice=voice, + tools=[tool_to_schema(tool) for tool in tools], + ) + + if is_first: + await client.connect(config) + else: + await client.update_session(config) + await client.send_text( + "The user has just been transferred to you. Greet them briefly and help " + "with whatever they were asking about." + ) + + async def send_audio() -> None: + try: + async for chunk in microphone.audio_generator(): + await client.send_audio(chunk) + except asyncio.CancelledError: + pass + + send_task = asyncio.create_task(send_audio()) + + ai_speaking = False + current_ai_text = "" + try: + async for event in client.events(): + if event.type == "session_update": + if "created" in event.data.get("raw_type", ""): + print(f"[Connected — speaking with {agent_def.display_name}]") + elif not is_first: + print(f"[Session updated — speaking with {agent_def.display_name}]") + + elif event.type == "tool_call": + tool_name = event.data.get("name", "unknown") + result = await execute_tool(tool_registry, event.data) + + if tool_name == "transfer" and transfer_target: + # Return immediately — don't send the tool result so the + # greeter won't generate a redundant farewell response. + # The greeter already spoke its farewell before calling the + # tool. update_session() will change the persona and + # send_text() will prompt the new agent to greet the user. + print(f" >> Transferring to: {transfer_target}") + return transfer_target + + await client.send_tool_result(event.data["id"], result) + tool_args = event.data.get("arguments", "{}") + print(f"\n>>> Tool called: {tool_name}") + print(f" Arguments: {tool_args}") + print(f" Result: {result}") + + elif event.type == "listening": + print("\n[You are speaking...]") + ai_speaking = False + current_ai_text = "" + player.clear() + + elif event.type == "audio": + audio = event.data.get("audio", b"") + if audio: + player.play(audio) + + elif event.type == "transcript": + text = event.data.get("text", "") + if text: + if not ai_speaking: + print(f"{agent_def.display_name}: ", end="", flush=True) + ai_speaking = True + print(text, end="", flush=True) + current_ai_text += text + + elif event.type == "speaking_done": + print() + ai_speaking = False + current_ai_text = "" + + elif event.type == "interrupted": + print("\n[Interrupted — listening...]") + ai_speaking = False + current_ai_text = "" + player.clear() + + elif event.type == "input_transcript": + text = event.data.get("text", "") + if text: + print(f"You: {text}") + + elif event.type == "error": + error = event.data.get("error", {}) + print(f"\n[Error: {error}]") + + except KeyboardInterrupt: + raise + except Exception as e: + print(f"\n[Agent error: {e}]") + finally: + send_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await send_task + + return transfer_target + + +async def main(client_type: str) -> None: + """Run a multi-agent voice conversation.""" + print("=== Realtime Voice Chat — Multiple Agents ===\n") + + if not check_pyaudio(): + return + + print(f"Using client type: {client_type}\n") + print("Agents available:") + for defn in AGENT_DEFINITIONS.values(): + print(f" - {defn.display_name} ({defn.name})") + + microphone = MicrophoneCapture() + player = AudioPlayer() + + print("\nStarting audio devices...") + microphone.start() + player.start() + + print("\n" + "=" * 50) + print("Multi-agent voice chat is now active!") + print("You'll start with the Greeter.") + print("Agents can transfer you to each other.") + print("Press Ctrl+C to end the conversation.") + print("=" * 50 + "\n") + + client = create_realtime_client(client_type) + current_agent = "greeter" + is_first = True + + try: + while current_agent: + agent_def = AGENT_DEFINITIONS[current_agent] + print(f"\n--- Now speaking with: {agent_def.display_name} ---\n") + + next_agent = await run_agent_session( + agent_def, client, microphone, player, is_first, + ) + is_first = False + + if next_agent and next_agent in AGENT_DEFINITIONS: + print(f"\n[Transferred to {AGENT_DEFINITIONS[next_agent].display_name}]") + current_agent = next_agent + else: + break + + except KeyboardInterrupt: + print("\n\n[Ending conversation...]") + except Exception as e: + print(f"\n[Error: {e}]") + finally: + await client.disconnect() + microphone.stop() + player.stop() + print("[Audio devices stopped]") + print("\nGoodbye!") + + +if __name__ == "__main__": + args = parse_args() + asyncio.run(main(args.client_type)) + + +""" +Sample Output: +=== Realtime Voice Chat — Multiple Agents === + +Using client type: azure_openai + +Agents available: + - Greeter (greeter) + - Support Agent (support) + - Assistant (assistant) + +Starting audio devices... + +================================================== +Multi-agent voice chat is now active! +You'll start with the Greeter. +Agents can transfer you to each other. +Press Ctrl+C to end the conversation. +================================================== + + +--- Now speaking with: Greeter --- + +[Connected — speaking with Greeter] +Greeter: Hi there! Welcome! I'm Alex, how can I help you today? + +[You are speaking...] +You: I need to check on my order. +Greeter: Sure, let me transfer you to our support team right away! + >> Transferring to: support + +[Transferred to Support Agent] + +--- Now speaking with: Support Agent --- + +[Session updated — speaking with Support Agent] +Support Agent: Hi! I'm Sam from support. What's your order number? + +[You are speaking...] +You: It's ORD-1001. + +>>> Tool called: lookup_order + Arguments: {"order_id": "ORD-1001"} + Result: Laptop - Shipped, arriving tomorrow + +Support Agent: Your laptop has shipped and should arrive tomorrow! + +[You are speaking...] +You: Great! What's the weather like in London? +Support Agent: Let me transfer you to our assistant for that! + >> Transferring to: assistant + +[Transferred to Assistant] + +--- Now speaking with: Assistant --- + +[Session updated — speaking with Assistant] + +>>> Tool called: get_weather + Arguments: {"location": "London"} + Result: The weather in London is cloudy with a temperature of 18°C. +""" diff --git a/python/samples/getting_started/realtime/realtime_with_tools.py b/python/samples/getting_started/realtime/realtime_with_tools.py new file mode 100644 index 0000000000..1ab6cc8274 --- /dev/null +++ b/python/samples/getting_started/realtime/realtime_with_tools.py @@ -0,0 +1,266 @@ +# Copyright (c) Microsoft. All rights reserved. + +import argparse +import asyncio +import os +from datetime import datetime, timezone +from random import randint +from typing import Annotated + +from agent_framework import BaseRealtimeClient, RealtimeAgent, tool +from agent_framework.azure import AzureOpenAIRealtimeClient, AzureVoiceLiveClient +from agent_framework.openai import OpenAIRealtimeClient +from audio_utils import AudioPlayer, MicrophoneCapture, check_pyaudio +from azure.identity import DefaultAzureCredential +from pydantic import Field + +""" +Realtime Voice Agent with Tools Example + +This sample demonstrates how to use function tools with realtime voice agents. +When the model needs to call a function, it will pause speaking, execute the tool, +and then continue the conversation with the result. + +Supported client types (set via --client-type or REALTIME_CLIENT_TYPE env var): +- openai: OpenAI Realtime API (requires OPENAI_API_KEY) +- azure_openai: Azure OpenAI Realtime API (requires AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY) +- azure_voice_live: Azure Voice Live API (requires AZURE_VOICELIVE_ENDPOINT, AZURE_VOICELIVE_API_KEY) + +Requirements: +- Environment variables set for the chosen client type +- pyaudio package: pip install pyaudio +- On macOS: brew install portaudio (before pip install pyaudio) + +The sample shows: +1. Defining tools using the @tool decorator +2. Registering tools with the RealtimeAgent +3. Using microphone input for natural voice conversations with tools +4. Handling tool_call events during voice conversations +5. Configuring the realtime client type via CLI or environment variable +""" + + +# Define tools using the @tool decorator +@tool +def get_weather( + location: Annotated[str, Field(description="The city to get weather for")], +) -> str: + """Get the current weather for a location.""" + conditions = ["sunny", "cloudy", "rainy", "partly cloudy"] + temp = randint(15, 30) + condition = conditions[randint(0, len(conditions) - 1)] + return f"The weather in {location} is {condition} with a temperature of {temp}°C." + + +@tool +def get_time( + timezone_name: Annotated[str, Field(description="Timezone like 'UTC' or 'America/New_York'")] = "UTC", +) -> str: + """Get the current time in a specified timezone.""" + # Simplified - just return UTC for demo + current_time = datetime.now(timezone.utc) + return f"The current time in {timezone_name} is {current_time.strftime('%I:%M %p')}." + + +@tool +def calculate( + expression: Annotated[str, Field(description="A math expression like '2 + 2' or '10 * 5'")], +) -> str: + """Evaluate a simple math expression.""" + try: + # Simple eval for demo - in production use a safer parser + allowed_chars = set("0123456789+-*/(). ") + if all(c in allowed_chars for c in expression): + result = eval(expression) + return f"The result of {expression} is {result}" + return "Invalid expression" + except Exception: + return "Could not evaluate expression" + + +def create_realtime_client(client_type: str) -> BaseRealtimeClient: + """Create a realtime client based on the specified type.""" + if client_type == "openai": + return OpenAIRealtimeClient(model_id="gpt-realtime") + if client_type == "azure_openai": + return AzureOpenAIRealtimeClient( + deployment_name="gpt-realtime", + credential=DefaultAzureCredential(), + ) + if client_type == "azure_voice_live": + return AzureVoiceLiveClient( + credential=DefaultAzureCredential(), + ) + raise ValueError(f"Unknown client type: {client_type}. Valid values: openai, azure_openai, azure_voice_live") + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description="Realtime Voice Agent with Tools") + parser.add_argument( + "--client-type", + type=str, + default=os.environ.get("REALTIME_CLIENT_TYPE", "openai"), + choices=["openai", "azure_openai", "azure_voice_live"], + help="The realtime client type to use (default: openai, env: REALTIME_CLIENT_TYPE)", + ) + return parser.parse_args() + + +async def main(client_type: str) -> None: + """Run a realtime voice session with tools.""" + print("=== Realtime Voice Agent with Tools ===\n") + + if not check_pyaudio(): + return + + print(f"Using client type: {client_type}\n") + + # 1. Create the realtime client + client = create_realtime_client(client_type) + + # 2. Create the agent with tools + agent = RealtimeAgent( + realtime_client=client, + name="ToolsAssistant", + instructions="""You are a helpful voice assistant with access to tools. + You can check the weather, tell the time, and do calculations. + When asked about these topics, use your tools to provide accurate information. + Keep your responses conversational and brief.""", + voice="alloy", + tools=[get_weather, get_time, calculate], + ) + + # 3. Set up audio I/O + microphone = MicrophoneCapture() + player = AudioPlayer() + + print("Available tools:") + print(" - get_weather(location): Get weather for a city") + print(" - get_time(timezone): Get current time") + print(" - calculate(expression): Evaluate math") + + print("\nStarting audio devices...") + microphone.start() + player.start() + + print("\n" + "=" * 50) + print("Voice chat with tools is now active!") + print("Try asking about the weather, time, or math.") + print("Press Ctrl+C to end the conversation.") + print("=" * 50 + "\n") + + # 4. Run the agent and observe tool execution + ai_speaking = False + try: + async for event in agent.run(audio_input=microphone.audio_generator()): + if event.type == "session_update": + if "created" in event.data.get("raw_type", ""): + print("[Connected to realtime API]") + + elif event.type == "tool_call": + tool_name = event.data.get("name", "unknown") + tool_args = event.data.get("arguments", "{}") + print(f"\n>>> Tool called: {tool_name}") + print(f"Arguments:\n{tool_args}") + + elif event.type == "tool_result": + result = event.data.get("result", "") + print(f"Result:\n{result}") + + elif event.type == "listening": + print("\n[You are speaking...]") + ai_speaking = False + player.clear() + + elif event.type == "audio": + audio = event.data.get("audio", b"") + if audio: + player.play(audio) + + elif event.type == "transcript": + text = event.data.get("text", "") + if text: + if not ai_speaking: + print("AI: ", end="", flush=True) + ai_speaking = True + print(text, end="", flush=True) + + elif event.type == "speaking_done": + print() + ai_speaking = False + + elif event.type == "interrupted": + print("\n[Interrupted - listening...]") + ai_speaking = False + player.clear() + + elif event.type == "input_transcript": + text = event.data.get("text", "") + if text: + print(f"You: {text}") + + elif event.type == "error": + error = event.data.get("error", {}) + print(f"\n[Error: {error}]") + + except KeyboardInterrupt: + print("\n\n[Ending conversation...]") + except Exception as e: + print(f"\n[Error: {e}]") + finally: + microphone.stop() + player.stop() + print("[Audio devices stopped]") + print("\nGoodbye!") + + +if __name__ == "__main__": + args = parse_args() + asyncio.run(main(args.client_type)) + + +""" +Sample Output: +=== Realtime Voice Agent with Tools === + +Using client type: openai + +Available tools: + - get_weather(location): Get weather for a city + - get_time(timezone): Get current time + - calculate(expression): Evaluate math + +Starting audio devices... + +================================================== +Voice chat with tools is now active! +Try asking about the weather, time, or math. +Press Ctrl+C to end the conversation. +================================================== + +[Connected to realtime API] + +[You are speaking...] +You: What's the weather like in Seattle? + +>>> Tool called: get_weather + Arguments: {"location": "Seattle"} + +AI: The weather in Seattle is partly cloudy with a temperature of 18 degrees Celsius. + +[You are speaking...] +You: What time is it? + +>>> Tool called: get_time + Arguments: {"timezone_name": "UTC"} + +AI: The current time in UTC is 3:45 PM. + +^C + +[Ending conversation...] +[Audio devices stopped] + +Goodbye! +""" diff --git a/python/samples/getting_started/realtime/websocket_audio_client.py b/python/samples/getting_started/realtime/websocket_audio_client.py new file mode 100644 index 0000000000..120d2412fe --- /dev/null +++ b/python/samples/getting_started/realtime/websocket_audio_client.py @@ -0,0 +1,252 @@ +# Copyright (c) Microsoft. All rights reserved. + +import argparse +import asyncio +import base64 +import json +import sys + +from audio_utils import AudioPlayer, MicrophoneCapture, check_pyaudio + +""" +WebSocket Audio Client + +A command-line utility that connects to the FastAPI WebSocket voice endpoint +(realtime_fastapi_websocket.py), captures microphone audio, sends it over the +WebSocket, and plays back AI audio responses through the speaker. + +Prerequisites: + pip install websockets pyaudio + + On macOS, install PortAudio first: + brew install portaudio + +Quick start: + 1. Set environment variables for your chosen client type: + + # Azure OpenAI + export REALTIME_CLIENT_TYPE=azure_openai + export AZURE_OPENAI_ENDPOINT=https://.openai.azure.com + export AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME=gpt-4o-realtime-preview + + # OpenAI + export REALTIME_CLIENT_TYPE=openai + export OPENAI_API_KEY=sk-... + + # Azure Voice Live + export REALTIME_CLIENT_TYPE=azure_voice_live + export AZURE_VOICELIVE_ENDPOINT=https://... + + 2. Start the FastAPI server (in the samples/getting_started/realtime/ dir): + uvicorn realtime_fastapi_websocket:app + + 3. In a separate terminal, run this client: + python websocket_audio_client.py + +Options: + --url WebSocket URL (default: ws://localhost:8000/ws/voice) + +WebSocket Protocol: + The client exchanges JSON messages with the server: + + Send (client -> server): + {"type": "audio", "audio": ""} + + Receive (server -> client): + {"type": "audio", "audio": ""} + {"type": "transcript", "text": "..."} + {"type": "input_transcript", "text": "..."} + {"type": "listening"} + {"type": "speaking_done"} + {"type": "tool_call", "name": "...", "arguments": "..."} + {"type": "tool_result", "name": "...", "result": "..."} + {"type": "error", "message": "..."} + {"type": "session_update", "status": "..."} +""" + +try: + import websockets + from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK +except ImportError: + print("Error: websockets not installed") + print("Install with: pip install websockets") + sys.exit(1) + + +DEFAULT_URL = "ws://localhost:8000/ws/voice" + + +async def main(url: str) -> None: + """Connect to the WebSocket endpoint and stream audio bidirectionally.""" + if not check_pyaudio(): + return + + print("=== WebSocket Audio Client ===") + print(f"Connecting to {url} ...") + + microphone = MicrophoneCapture() + player = AudioPlayer() + + try: + async with websockets.connect( + url, + ping_interval=20, + ping_timeout=20, + close_timeout=5, + ) as ws: + print("[Connected]\n") + + microphone.start() + player.start() + + print("Speak into your microphone. Press Ctrl+C to quit.\n") + + # Run send and receive loops concurrently + send_task = asyncio.create_task(_send_audio(ws, microphone)) + receive_task = asyncio.create_task( + _receive_events(ws, player), + ) + + done, pending = await asyncio.wait( + [send_task, receive_task], + return_when=asyncio.FIRST_EXCEPTION, + ) + + for task in pending: + task.cancel() + for task in done: + exc = task.exception() + if exc and not isinstance(exc, (ConnectionClosedOK, ConnectionClosedError)): + raise exc + + except ConnectionRefusedError: + print(f"\n[Error: Could not connect to {url}]") + print("Make sure the FastAPI server is running:") + print(" uvicorn realtime_fastapi_websocket:app") + except (ConnectionClosedError, ConnectionClosedOK): + print("\n[Server closed the connection]") + print("Check the server terminal for errors.") + except KeyboardInterrupt: + print("\n\n[Disconnected]") + except Exception as e: + print(f"\n[Error: {e}]") + finally: + microphone.stop() + player.stop() + print("Goodbye!") + + +async def _send_audio(ws, microphone: MicrophoneCapture) -> None: + """Stream microphone audio to the WebSocket as base64 JSON messages.""" + try: + async for chunk in microphone.audio_generator(): + message = json.dumps({ + "type": "audio", + "audio": base64.b64encode(chunk).decode("utf-8"), + }) + await ws.send(message) + except (asyncio.CancelledError, ConnectionClosedError, ConnectionClosedOK): + pass + + +async def _receive_events(ws, player: AudioPlayer) -> None: + """Receive events from the WebSocket and handle audio/text.""" + ai_speaking = False + try: + async for raw in ws: + msg = json.loads(raw) + msg_type = msg.get("type", "") + + if msg_type == "audio": + audio_b64 = msg.get("audio", "") + if audio_b64: + player.play(base64.b64decode(audio_b64)) + + elif msg_type == "transcript": + text = msg.get("text", "") + if text: + if not ai_speaking: + print("AI: ", end="", flush=True) + ai_speaking = True + print(text, end="", flush=True) + + elif msg_type == "listening": + print("\n[You are speaking...]") + ai_speaking = False + player.clear() + + elif msg_type == "speaking_done": + print() + ai_speaking = False + + elif msg_type == "tool_call": + name = msg.get("name", "unknown") + args = msg.get("arguments", "{}") + print(f"\n>>> Tool called: {name}") + print(f" Arguments: {args}") + + elif msg_type == "tool_result": + name = msg.get("name", "unknown") + result = msg.get("result", "") + print(f" Result: {result}") + + elif msg_type == "input_transcript": + text = msg.get("text", "") + if text: + print(f"You: {text}") + + elif msg_type == "error": + detail = msg.get("message", msg.get("details", "")) + print(f"\n[Error: {detail}]") + + elif msg_type == "session_update": + status = msg.get("status", "") + if "created" in status: + print("[Session ready]") + + except (asyncio.CancelledError, ConnectionClosedError, ConnectionClosedOK): + pass + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="WebSocket Audio Client for realtime voice endpoint") + parser.add_argument( + "--url", + type=str, + default=DEFAULT_URL, + help=f"WebSocket URL to connect to (default: {DEFAULT_URL})", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + asyncio.run(main(args.url)) + + +""" +Sample Output: +=== WebSocket Audio Client === +Connecting to ws://localhost:8000/ws/voice ... +[Connected] + +Speak into your microphone. Press Ctrl+C to quit. + +[Session ready] + +[You are speaking...] +You: Hello, what can you do? +AI: Hi there! I can check the weather and tell you the current time. What would you like to know? + +[You are speaking...] +You: What's the weather in Paris? + +>>> Tool called: get_weather + Arguments: {"location": "Paris"} +AI: The weather in Paris is sunny with a temperature of 22 degrees Celsius. + +^C + +[Disconnected] +Goodbye! +""" diff --git a/python/uv.lock b/python/uv.lock index fac55c8e21..97a8992f0d 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -32,6 +32,7 @@ members = [ "agent-framework-anthropic", "agent-framework-azure-ai", "agent-framework-azure-ai-search", + "agent-framework-azure-voice-live", "agent-framework-azurefunctions", "agent-framework-bedrock", "agent-framework-chatkit", @@ -235,6 +236,21 @@ requires-dist = [ { name = "azure-search-documents", specifier = "==11.7.0b2" }, ] +[[package]] +name = "agent-framework-azure-voice-live" +version = "1.0.0b260116" +source = { editable = "packages/azure-ai-voice-live" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-voicelive", extra = ["aiohttp"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "azure-ai-voicelive", extras = ["aiohttp"], specifier = ">=1.0.0" }, +] + [[package]] name = "agent-framework-azurefunctions" version = "1.0.0b260210" @@ -989,6 +1005,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/b6/8fbd4786bb5c0dd19eaff86ddce0fbfb53a6f90d712038272161067a076a/azure_ai_projects-2.0.0b3-py3-none-any.whl", hash = "sha256:3b3048a3ba3904d556ba392b7bd20b6e84c93bb39df6d43a6470cdb0ad08af8c", size = 240717, upload-time = "2026-01-06T05:31:27.716Z" }, ] +[[package]] +name = "azure-ai-voicelive" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core", 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 = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/46/e304076e2bdca64a3b77bf9b6c79b8b4f29b994cc22196ff8c72b93faf09/azure_ai_voicelive-1.1.0.tar.gz", hash = "sha256:9398d0a3ad8a3c43844e89dfb8c61a39422f294770820f405b114c4d752d3f43", size = 127417, upload-time = "2025-11-04T18:26:00.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/4b/48a81dae63b3fa1208603a71bffda884bc9cec46f04d223e867efd132357/azure_ai_voicelive-1.1.0-py3-none-any.whl", hash = "sha256:29f2ab8bef67dd41cddafb0239f059351ddb44a7d95bcc4e706516e0c2bfdcc4", size = 83156, upload-time = "2025-11-04T18:26:01.214Z" }, +] + +[package.optional-dependencies] +aiohttp = [ + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + [[package]] name = "azure-common" version = "1.1.28" @@ -1087,6 +1122,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/3a/6ef2047a072e54e1142718d433d50e9514c999a58f51abfff7902f3a72f8/azure_storage_blob-12.28.0-py3-none-any.whl", hash = "sha256:00fb1db28bf6a7b7ecaa48e3b1d5c83bfadacc5a678b77826081304bd87d6461", size = 431499, upload-time = "2026-01-06T23:48:58.995Z" }, ] +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + [[package]] name = "backoff" version = "2.2.1" From 087a3eb990ed43f88e52926ce26d2a6dfc6939b0 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 08:53:39 +0000 Subject: [PATCH 02/20] fix(realtime): track connection state in OpenAI and Azure OpenAI clients - Set _connected = True after successful connect() - Set _connected = False at the start of disconnect() - Applied to both OpenAIRealtimeClient and AzureOpenAIRealtimeClient - AzureVoiceLiveClient already tracked this correctly --- python/packages/core/agent_framework/azure/_realtime_client.py | 2 ++ python/packages/core/agent_framework/openai/_realtime_client.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/python/packages/core/agent_framework/azure/_realtime_client.py b/python/packages/core/agent_framework/azure/_realtime_client.py index 3e61f2afbc..6d5f352e80 100644 --- a/python/packages/core/agent_framework/azure/_realtime_client.py +++ b/python/packages/core/agent_framework/azure/_realtime_client.py @@ -200,6 +200,7 @@ async def connect(self, config: RealtimeSessionConfig) -> None: session_config = self._build_session_config(config) await self._connection.session.update(session=session_config) # type: ignore[arg-type] + self._connected = True async def update_session(self, config: RealtimeSessionConfig) -> None: """Update session configuration on an existing connection. @@ -222,6 +223,7 @@ async def update_session(self, config: RealtimeSessionConfig) -> None: async def disconnect(self) -> None: """Disconnect from Azure OpenAI Realtime API.""" + self._connected = False if self._connection_manager: with contextlib.suppress(Exception): await self._connection_manager.__aexit__(None, None, None) diff --git a/python/packages/core/agent_framework/openai/_realtime_client.py b/python/packages/core/agent_framework/openai/_realtime_client.py index e5c104b13e..e1eee58613 100644 --- a/python/packages/core/agent_framework/openai/_realtime_client.py +++ b/python/packages/core/agent_framework/openai/_realtime_client.py @@ -164,6 +164,7 @@ async def connect(self, config: RealtimeSessionConfig) -> None: session_config = self._build_session_config(config) await self._connection.session.update(session=session_config) # type: ignore[arg-type] + self._connected = True async def update_session(self, config: RealtimeSessionConfig) -> None: """Update session configuration on an existing connection. @@ -186,6 +187,7 @@ async def update_session(self, config: RealtimeSessionConfig) -> None: async def disconnect(self) -> None: """Disconnect from OpenAI Realtime API.""" + self._connected = False if self._connection_manager: with contextlib.suppress(Exception): await self._connection_manager.__aexit__(None, None, None) From ff7c2cfcbf2fb78abe85686cc86437357701c019 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 08:56:20 +0000 Subject: [PATCH 03/20] fix(realtime): clear pending function names on disconnect - Clear _pending_function_names in disconnect() for both OpenAI and Azure OpenAI clients to avoid leaking per-connection state across reconnects --- .../agent_framework/azure/_realtime_client.py | 1 + .../openai/_realtime_client.py | 1 + python/uv.lock | 405 +++++++++--------- 3 files changed, 200 insertions(+), 207 deletions(-) diff --git a/python/packages/core/agent_framework/azure/_realtime_client.py b/python/packages/core/agent_framework/azure/_realtime_client.py index 6d5f352e80..7c3b9d7a97 100644 --- a/python/packages/core/agent_framework/azure/_realtime_client.py +++ b/python/packages/core/agent_framework/azure/_realtime_client.py @@ -224,6 +224,7 @@ async def update_session(self, config: RealtimeSessionConfig) -> None: async def disconnect(self) -> None: """Disconnect from Azure OpenAI Realtime API.""" self._connected = False + self._pending_function_names.clear() if self._connection_manager: with contextlib.suppress(Exception): await self._connection_manager.__aexit__(None, None, None) diff --git a/python/packages/core/agent_framework/openai/_realtime_client.py b/python/packages/core/agent_framework/openai/_realtime_client.py index e1eee58613..3c38232072 100644 --- a/python/packages/core/agent_framework/openai/_realtime_client.py +++ b/python/packages/core/agent_framework/openai/_realtime_client.py @@ -188,6 +188,7 @@ async def update_session(self, config: RealtimeSessionConfig) -> None: async def disconnect(self) -> None: """Disconnect from OpenAI Realtime API.""" self._connected = False + self._pending_function_names.clear() if self._connection_manager: with contextlib.suppress(Exception): await self._connection_manager.__aexit__(None, None, None) diff --git a/python/uv.lock b/python/uv.lock index 97a8992f0d..54b6b06896 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1035,15 +1035,15 @@ wheels = [ [[package]] name = "azure-core" -version = "1.38.0" +version = "1.38.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests", 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/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/9b/23893febea484ad8183112c9419b5eb904773adb871492b5fa8ff7b21e09/azure_core-1.38.1.tar.gz", hash = "sha256:9317db1d838e39877eb94a2240ce92fa607db68adf821817b723f0d679facbf6", size = 363323, upload-time = "2026-02-11T02:03:06.051Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/db/88/aaea2ad269ce70b446660371286272c1f6ba66541a7f6f635baf8b0db726/azure_core-1.38.1-py3-none-any.whl", hash = "sha256:69f08ee3d55136071b7100de5b198994fc1c5f89d2b91f2f43156d20fcf200a4", size = 217930, upload-time = "2026-02-11T02:03:07.548Z" }, ] [[package]] @@ -1078,7 +1078,7 @@ wheels = [ [[package]] name = "azure-identity" -version = "1.25.1" +version = "1.25.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1087,9 +1087,9 @@ dependencies = [ { name = "msal-extensions", 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/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709, upload-time = "2026-02-11T01:55:42.323Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" }, + { url = "https://files.pythonhosted.org/packages/9b/77/f658c76f9e9a52c784bd836aaca6fd5b9aae176f1f53273e758a2bcda695/azure_identity-1.25.2-py3-none-any.whl", hash = "sha256:1b40060553d01a72ba0d708b9a46d0f61f56312e215d8896d836653ffdc6753d", size = 191423, upload-time = "2026-02-11T01:55:44.245Z" }, ] [[package]] @@ -1122,15 +1122,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/3a/6ef2047a072e54e1142718d433d50e9514c999a58f51abfff7902f3a72f8/azure_storage_blob-12.28.0-py3-none-any.whl", hash = "sha256:00fb1db28bf6a7b7ecaa48e3b1d5c83bfadacc5a678b77826081304bd87d6461", size = 431499, upload-time = "2026-01-06T23:48:58.995Z" }, ] -[[package]] -name = "babel" -version = "2.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, -] - [[package]] name = "backoff" version = "2.2.1" @@ -1368,19 +1359,19 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.1.34" +version = "0.1.35" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", 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 = "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/9d/69/faeb64e9c8f0962cbf12bee1b959acc41f87c82947ec7074a6780b417001/claude_agent_sdk-0.1.34.tar.gz", hash = "sha256:db9e4023a754d9a58a0793666fe9174ead277197cd896156d2f8784cc73c5006", size = 61196, upload-time = "2026-02-10T01:04:00.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/b9/60f21337cfb0d029cbafefdb5de5d043a5d84e44f286f04dbadda7fda932/claude_agent_sdk-0.1.35.tar.gz", hash = "sha256:0f98e2b3c71ca85abfc042e7a35c648df88e87fda41c52e6779ef7b038dcbb52", size = 61194, upload-time = "2026-02-10T23:21:04.114Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/7b/2ccdc12b553a61b59b0470f1cf3b0a864c79ab8a4ac8013ea22fa3e6c461/claude_agent_sdk-0.1.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:18569ab4bfb5451c4aacb51c0d44eb9802d18d8442d30c29f32b6e8a2479d210", size = 54604881, upload-time = "2026-02-10T01:03:44.575Z" }, - { url = "https://files.pythonhosted.org/packages/38/59/335b213fb3342c4405fa992cc9e45e52e18a543068f0574ae84010dc4c08/claude_agent_sdk-0.1.34-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:d7ecd7421066e405376d3feca21ccb3e9245506ba7c219858f7a7f0129877cdb", size = 69359030, upload-time = "2026-02-10T01:03:49.113Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/28c715efdad7b5c413e046f5f914d9ab888d25f2c3bb9233f1164d58d2be/claude_agent_sdk-0.1.34-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:82e9148410ec98ff4061e43e85601d8f0a2e8568d897ab82c324ccf11c297fc5", size = 69949555, upload-time = "2026-02-10T01:03:53.525Z" }, - { url = "https://files.pythonhosted.org/packages/12/3d/843159343b20d6c9b44cf4a7fe46b568d5b448276ba8ba4c49178d26ba4c/claude_agent_sdk-0.1.34-py3-none-win_amd64.whl", hash = "sha256:a64031a9bf5c70388a6a84368d350d68586d9854a1539f494b46ec6d0b6acf93", size = 72493949, upload-time = "2026-02-10T01:03:57.839Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1a/68ca97f034a1773bd234a4572e1a660d3f37b93e8ab9c8da95c36a10fd00/claude_agent_sdk-0.1.35-py3-none-macosx_11_0_arm64.whl", hash = "sha256:df67f4deade77b16a9678b3a626c176498e40417f33b04beda9628287f375591", size = 54665881, upload-time = "2026-02-10T23:20:48.107Z" }, + { url = "https://files.pythonhosted.org/packages/4e/56/535f23919882397571e15f4fb0418897ba9b527dc3a8a6c84b4f537486e0/claude_agent_sdk-0.1.35-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:14963944f55ded7c8ed518feebfa5b4284aa6dd8d81aeff2e5b21a962ce65097", size = 69419673, upload-time = "2026-02-10T23:20:53.463Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b5/763dda7d4d8c12a2ce485ef5cc98f2e7d2699bf3e0d8e2a7fb294d54c34b/claude_agent_sdk-0.1.35-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:84344dcc535d179c1fc8a11c6f34c37c3b583447bdf09d869effb26514fd7a65", size = 70007339, upload-time = "2026-02-10T23:20:57.629Z" }, + { url = "https://files.pythonhosted.org/packages/9e/89/10f3d0355ee873203104714d2d728315ee5919c793e3626fab94a91ea29b/claude_agent_sdk-0.1.35-py3-none-win_amd64.whl", hash = "sha256:1b3d54b47448c93f6f372acd4d1757f047c3c1e8ef5804be7a1e3e53e2c79a5f", size = 72528072, upload-time = "2026-02-10T23:21:01.418Z" }, ] [[package]] @@ -1400,7 +1391,7 @@ name = "clr-loader" version = "0.2.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" } wheels = [ @@ -1717,62 +1708,62 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.4" +version = "46.0.5" 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')" }, { 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/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, - { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, - { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, - { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, - { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, - { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, - { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, - { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, - { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, - { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, - { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, - { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, - { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, - { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, - { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, - { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, - { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, - { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, - { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, - { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, - { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, - { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, - { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, - { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, - { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, - { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, - { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] [[package]] @@ -1879,7 +1870,7 @@ name = "exceptiongroup" version = "1.3.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')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1897,7 +1888,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.128.6" +version = "0.128.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1906,9 +1897,9 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/d1/195005b5e45b443e305136df47ee7df4493d782e0c039dd0d97065580324/fastapi-0.128.6.tar.gz", hash = "sha256:0cb3946557e792d731b26a42b04912f16367e3c3135ea8290f620e234f2b604f", size = 374757, upload-time = "2026-02-09T17:27:03.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/58/a2c4f6b240eeb148fb88cdac48f50a194aba760c1ca4988c6031c66a20ee/fastapi-0.128.6-py3-none-any.whl", hash = "sha256:bb1c1ef87d6086a7132d0ab60869d6f1ee67283b20fbf84ec0003bd335099509", size = 103674, upload-time = "2026-02-09T17:27:02.355Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" }, ] [[package]] @@ -3103,7 +3094,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.81.9" +version = "1.81.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3119,9 +3110,9 @@ dependencies = [ { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/8f/2a08f3d86fd008b4b02254649883032068378a8551baed93e8d9dcbbdb5d/litellm-1.81.9.tar.gz", hash = "sha256:a2cd9bc53a88696c21309ef37c55556f03c501392ed59d7f4250f9932917c13c", size = 16276983, upload-time = "2026-02-07T21:14:24.473Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/fc/78887158b4057835ba2c647a1bd4da650fd79142f8412c6d0bbe6d8c6081/litellm-1.81.10.tar.gz", hash = "sha256:8d769a7200888e1295592af5ce5cb0ff035832250bd0102a4ca50acf5820ca50", size = 16297572, upload-time = "2026-02-11T00:17:47.347Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/8b/672fc06c8a2803477e61e0de383d3c6e686e0f0fc62789c21f0317494076/litellm-1.81.9-py3-none-any.whl", hash = "sha256:24ee273bc8a62299fbb754035f83fb7d8d44329c383701a2bd034f4fd1c19084", size = 14433170, upload-time = "2026-02-07T21:14:21.469Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bb/3f3cc3d79657bc9daaa1319ec3a9d75e4889fc88d07e327f0ac02cd2ac7d/litellm-1.81.10-py3-none-any.whl", hash = "sha256:9efa1cbe61ac051f6500c267b173d988ff2d511c2eecf1c8f2ee546c0870747c", size = 14457931, upload-time = "2026-02-11T00:17:43.431Z" }, ] [package.optional-dependencies] @@ -3163,11 +3154,11 @@ wheels = [ [[package]] name = "litellm-proxy-extras" -version = "0.4.33" +version = "0.4.34" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/4f/1e8644cdda2892d2dc8151153ca4d8a6fc44000363677a52f9988e56713a/litellm_proxy_extras-0.4.33.tar.gz", hash = "sha256:133dc5476b540d99e75d4baef622267e7344ced97737c174679baff429e7f212", size = 23973, upload-time = "2026-02-07T19:07:32.67Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/0f/e04f9718ddfc7a87b682e0eb98f18a5179dbe497e5d02a76ebe6aaae7269/litellm_proxy_extras-0.4.34.tar.gz", hash = "sha256:39fa6c2295acc449320b5a710d150295fd0bf5f8c0d1742b5e9ae361d7bd3ed2", size = 24232, upload-time = "2026-02-10T21:59:31.948Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/c0/b9960391b983306c39f1fa28e2eedf5d0e2048879fde8707a2d80896ed10/litellm_proxy_extras-0.4.33-py3-none-any.whl", hash = "sha256:bebea1b091490df19cfa773bd311f08254dee5bb53f92d282b7a5bdfba936334", size = 52533, upload-time = "2026-02-07T19:07:31.665Z" }, + { url = "https://files.pythonhosted.org/packages/21/71/a32bdfa74c598dde072d860ba1facaa522b4ef75c07c894c614999e73d75/litellm_proxy_extras-0.4.34-py3-none-any.whl", hash = "sha256:d455eb54f82e7c92f4f68a921240822df23158aad05fcdda7245887db7c30b90", size = 53171, upload-time = "2026-02-10T21:59:30.728Z" }, ] [[package]] @@ -3928,7 +3919,7 @@ wheels = [ [[package]] name = "openai" -version = "2.18.0" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3940,9 +3931,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/9e/cb/f2c9f988a06d1fcdd18ddc010f43ac384219a399eb01765493d6b34b1461/openai-2.18.0.tar.gz", hash = "sha256:5018d3bcb6651c5aac90e6d0bf9da5cde1bdd23749f67b45b37c522b6e6353af", size = 632124, upload-time = "2026-02-09T21:42:18.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/5a/f495777c02625bfa18212b6e3b73f1893094f2bf660976eb4bc6f43a1ca2/openai-2.20.0.tar.gz", hash = "sha256:2654a689208cd0bf1098bb9462e8d722af5cbe961e6bba54e6f19fb843d88db1", size = 642355, upload-time = "2026-02-10T19:02:54.145Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/5f/8940e0641c223eaf972732b3154f2178a968290f8cb99e8c88582cde60ed/openai-2.18.0-py3-none-any.whl", hash = "sha256:538f97e1c77a00e3a99507688c878cda7e9e63031807ba425c68478854d48b30", size = 1069897, upload-time = "2026-02-09T21:42:16.4Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a0/cf4297aa51bbc21e83ef0ac018947fa06aea8f2364aad7c96cbf148590e6/openai-2.20.0-py3-none-any.whl", hash = "sha256:38d989c4b1075cd1f76abc68364059d822327cf1a932531d429795f4fc18be99", size = 1098479, upload-time = "2026-02-10T19:02:52.157Z" }, ] [[package]] @@ -4397,100 +4388,100 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" }, - { url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" }, - { url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" }, - { url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" }, - { url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" }, - { url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" }, - { url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" }, - { url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, - { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, - { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, - { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, - { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, - { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, - { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, - { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, - { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, - { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, - { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, - { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, - { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, - { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, - { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, - { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, - { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, - { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, - { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, - { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, - { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, - { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, - { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, - { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, - { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, - { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, - { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, - { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, - { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, - { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, - { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, - { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, - { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, - { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, - { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, - { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, - { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, - { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, - { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, - { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, - { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, - { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, - { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, - { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, - { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, - { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, - { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, - { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, ] [[package]] @@ -4609,8 +4600,8 @@ name = "powerfx" version = "0.0.34" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, - { name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pythonnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/fb/6c4bf87e0c74ca1c563921ce89ca1c5785b7576bca932f7255cdf81082a7/powerfx-0.0.34.tar.gz", hash = "sha256:956992e7afd272657ed16d80f4cad24ec95d9e4a79fb9dfa4a068a09e136af32", size = 3237555, upload-time = "2025-12-22T15:50:59.682Z" } wheels = [ @@ -5259,7 +5250,7 @@ name = "pythonnet" version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } wheels = [ @@ -6515,15 +6506,15 @@ wheels = [ [[package]] name = "typer-slim" -version = "0.21.1" +version = "0.21.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "click", 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/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478, upload-time = "2026-01-06T11:21:11.176Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ca/0d9d822fd8a4c7e830cba36a2557b070d4b4a9558a0460377a61f8fb315d/typer_slim-0.21.2.tar.gz", hash = "sha256:78f20d793036a62aaf9c3798306142b08261d4b2a941c6e463081239f062a2f9", size = 120497, upload-time = "2026-02-10T19:33:45.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444, upload-time = "2026-01-06T11:21:12.441Z" }, + { url = "https://files.pythonhosted.org/packages/54/03/e09325cfc40a33a82b31ba1a3f1d97e85246736856a45a43b19fcb48b1c2/typer_slim-0.21.2-py3-none-any.whl", hash = "sha256:4705082bb6c66c090f60e47c8be09a93158c139ce0aa98df7c6c47e723395e5f", size = 56790, upload-time = "2026-02-10T19:33:47.221Z" }, ] [[package]] @@ -6609,27 +6600,27 @@ wheels = [ [[package]] name = "uv" -version = "0.10.0" +version = "0.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/36/f7fe4de0ad81234ac43938fe39c6ba84595c6b3a1868d786a4d7ad19e670/uv-0.10.0.tar.gz", hash = "sha256:ad01dd614a4bb8eb732da31ade41447026427397c5ad171cc98bd59579ef57ea", size = 3854103, upload-time = "2026-02-05T20:57:55.248Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/69/33fb64aee6ba138b1aaf957e20778e94a8c23732e41cdf68e6176aa2cf4e/uv-0.10.0-py3-none-linux_armv6l.whl", hash = "sha256:38dc0ccbda6377eb94095688c38e5001b8b40dfce14b9654949c1f0b6aa889df", size = 21984662, upload-time = "2026-02-05T20:57:19.076Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5a/e3ff8a98cfbabc5c2d09bf304d2d9d2d7b2e7d60744241ac5ed762015e5c/uv-0.10.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a165582c1447691109d49d09dccb065d2a23852ff42bf77824ff169909aa85da", size = 21057249, upload-time = "2026-02-05T20:56:48.921Z" }, - { url = "https://files.pythonhosted.org/packages/ee/77/ec8f24f8d0f19c4fda0718d917bb78b9e6f02a4e1963b401f1c4f4614a54/uv-0.10.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aefea608971f4f23ac3dac2006afb8eb2b2c1a2514f5fee1fac18e6c45fd70c4", size = 19827174, upload-time = "2026-02-05T20:57:10.581Z" }, - { url = "https://files.pythonhosted.org/packages/c6/7e/09b38b93208906728f591f66185a425be3acdb97c448460137d0e6ecb30a/uv-0.10.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d4b621bcc5d0139502789dc299bae8bf55356d07b95cb4e57e50e2afcc5f43e1", size = 21629522, upload-time = "2026-02-05T20:57:29.959Z" }, - { url = "https://files.pythonhosted.org/packages/89/f3/48d92c90e869331306979efaa29a44c3e7e8376ae343edc729df0d534dfb/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:b4bea728a6b64826d0091f95f28de06dd2dc786384b3d336a90297f123b4da0e", size = 21614812, upload-time = "2026-02-05T20:56:58.103Z" }, - { url = "https://files.pythonhosted.org/packages/ff/43/d0dedfcd4fe6e36cabdbeeb43425cd788604db9d48425e7b659d0f7ba112/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc0cc2a4bcf9efbff9a57e2aed21c2d4b5a7ec2cc0096e0c33d7b53da17f6a3b", size = 21577072, upload-time = "2026-02-05T20:57:45.455Z" }, - { url = "https://files.pythonhosted.org/packages/c5/90/b8c9320fd8d86f356e37505a02aa2978ed28f9c63b59f15933e98bce97e5/uv-0.10.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:070ca2f0e8c67ca9a8f70ce403c956b7ed9d51e0c2e9dbbcc4efa5e0a2483f79", size = 22829664, upload-time = "2026-02-05T20:57:22.689Z" }, - { url = "https://files.pythonhosted.org/packages/56/9c/2c36b30b05c74b2af0e663e0e68f1d10b91a02a145e19b6774c121120c0b/uv-0.10.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8070c66149c06f9b39092a06f593a2241345ea2b1d42badc6f884c2cc089a1b1", size = 23705815, upload-time = "2026-02-05T20:57:37.604Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a1/8c7fdb14ab72e26ca872e07306e496a6b8cf42353f9bf6251b015be7f535/uv-0.10.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3db1d5390b3a624de672d7b0f9c9d8197693f3b2d3d9c4d9e34686dcbc34197a", size = 22890313, upload-time = "2026-02-05T20:57:26.35Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f8/5c152350b1a6d0af019801f91a1bdeac854c33deb36275f6c934f0113cb5/uv-0.10.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b46db718763bf742e986ebbc7a30ca33648957a0dcad34382970b992f5e900", size = 22769440, upload-time = "2026-02-05T20:56:53.859Z" }, - { url = "https://files.pythonhosted.org/packages/87/44/980e5399c6f4943b81754be9b7deb87bd56430e035c507984e17267d6a97/uv-0.10.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:eb95d28590edd73b8fdd80c27d699c45c52f8305170c6a90b830caf7f36670a4", size = 21695296, upload-time = "2026-02-05T20:57:06.732Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e7/f44ad40275be2087b3910df4678ed62cf0c82eeb3375c4a35037a79747db/uv-0.10.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5871eef5046a81df3f1636a3d2b4ccac749c23c7f4d3a4bae5496cb2876a1814", size = 22424291, upload-time = "2026-02-05T20:57:49.067Z" }, - { url = "https://files.pythonhosted.org/packages/c2/81/31c0c0a8673140756e71a1112bf8f0fcbb48a4cf4587a7937f5bd55256b6/uv-0.10.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:1af0ec125a07edb434dfaa98969f6184c1313dbec2860c3c5ce2d533b257132a", size = 22109479, upload-time = "2026-02-05T20:57:02.258Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d1/2eb51bc233bad3d13ad64a0c280fd4d1ebebf5c2939b3900a46670fa2b91/uv-0.10.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:45909b9a734250da05b10101e0a067e01ffa2d94bbb07de4b501e3cee4ae0ff3", size = 22972087, upload-time = "2026-02-05T20:57:52.847Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f7/49987207b87b5c21e1f0e81c52892813e8cdf7e318b6373d6585773ebcdd/uv-0.10.0-py3-none-win32.whl", hash = "sha256:d5498851b1f07aa9c9af75578b2029a11743cb933d741f84dcbb43109a968c29", size = 20896746, upload-time = "2026-02-05T20:57:33.426Z" }, - { url = "https://files.pythonhosted.org/packages/80/b2/1370049596c6ff7fa1fe22fccf86a093982eac81017b8c8aff541d7263b2/uv-0.10.0-py3-none-win_amd64.whl", hash = "sha256:edd469425cd62bcd8c8cc0226c5f9043a94e37ed869da8268c80fdbfd3e5015e", size = 23433041, upload-time = "2026-02-05T20:57:41.41Z" }, - { url = "https://files.pythonhosted.org/packages/e3/76/1034c46244feafec2c274ac52b094f35d47c94cdb11461c24cf4be8a0c0c/uv-0.10.0-py3-none-win_arm64.whl", hash = "sha256:e90c509749b3422eebb54057434b7119892330d133b9690a88f8a6b0f3116be3", size = 21880261, upload-time = "2026-02-05T20:57:14.724Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/0d/9a/fe74aa0127cdc26141364e07abf25e5d69b4bf9788758fad9cfecca637aa/uv-0.10.2.tar.gz", hash = "sha256:b5016f038e191cc9ef00e17be802f44363d1b1cc3ef3454d1d76839a4246c10a", size = 3858864, upload-time = "2026-02-10T19:17:51.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/b5/aea88f66284d220be56ef748ed5e1bd11d819be14656a38631f4b55bfd48/uv-0.10.2-py3-none-linux_armv6l.whl", hash = "sha256:69e35aa3e91a245b015365e5e6ca383ecf72a07280c6d00c17c9173f2d3b68ab", size = 22215714, upload-time = "2026-02-10T19:17:34.281Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/947ba7737ae6cd50de61d268781b9e7717caa3b07e18238ffd547f9fc728/uv-0.10.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0b7eef95c36fe92e7aac399c0dce555474432cbfeaaa23975ed83a63923f78fd", size = 21276485, upload-time = "2026-02-10T19:18:15.415Z" }, + { url = "https://files.pythonhosted.org/packages/d3/38/5c3462b927a93be4ccaaa25138926a5fb6c9e1b72884efd7af77e451d82e/uv-0.10.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:acc08e420abab21de987151059991e3f04bc7f4044d94ca58b5dd547995b4843", size = 20048620, upload-time = "2026-02-10T19:17:26.481Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/d4509b0f5b7740c1af82202e9c69b700d5848b8bd0faa25229e8edd2c19c/uv-0.10.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:aefbcd749ab2ad48bb533ec028607607f7b03be11c83ea152dbb847226cd6285", size = 21870454, upload-time = "2026-02-10T19:17:21.838Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7e/2bcbafcb424bb885817a7e58e6eec9314c190c55935daaafab1858bb82cd/uv-0.10.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fad554c38d9988409ceddfac69a465e6e5f925a8b689e7606a395c20bb4d1d78", size = 21839508, upload-time = "2026-02-10T19:17:59.211Z" }, + { url = "https://files.pythonhosted.org/packages/60/08/16df2c1f8ad121a595316b82f6e381447e8974265b2239c9135eb874f33b/uv-0.10.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6dd2dc41043e92b3316d7124a7bf48c2affe7117c93079419146f083df71933c", size = 21841283, upload-time = "2026-02-10T19:17:41.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/a869fec4c03af5e43db700fabe208d8ee8dbd56e0ff568ba792788d505cd/uv-0.10.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111c05182c5630ac523764e0ec2e58d7b54eb149dbe517b578993a13c2f71aff", size = 23111967, upload-time = "2026-02-10T19:18:11.764Z" }, + { url = "https://files.pythonhosted.org/packages/2a/4a/fb38515d966acfbd80179e626985aab627898ffd02c70205850d6eb44df1/uv-0.10.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45c3deaba0343fd27ab5385d6b7cde0765df1a15389ee7978b14a51c32895662", size = 23911019, upload-time = "2026-02-10T19:18:26.947Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5f/51bcbb490ddb1dcb06d767f0bde649ad2826686b9e30efa57f8ab2750a1d/uv-0.10.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb2cac4f3be60b64a23d9f035019c30a004d378b563c94f60525c9591665a56b", size = 23030217, upload-time = "2026-02-10T19:17:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/144f6db851d49aa6f25b040dc5c8c684b8f92df9e8d452c7abc619c6ec23/uv-0.10.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937687df0380d636ceafcb728cf6357f0432588e721892128985417b283c3b54", size = 23036452, upload-time = "2026-02-10T19:18:18.97Z" }, + { url = "https://files.pythonhosted.org/packages/66/29/3c7c4559c9310ed478e3d6c585ee0aad2852dc4d5fb14f4d92a2a12d1728/uv-0.10.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f90bca8703ae66bccfcfb7313b4b697a496c4d3df662f4a1a2696a6320c47598", size = 21941903, upload-time = "2026-02-10T19:17:30.575Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5a/42883b5ef2ef0b1bc5b70a1da12a6854a929ff824aa8eb1a5571fb27a39b/uv-0.10.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cca026c2e584788e1264879a123bf499dd8f169b9cafac4a2065a416e09d3823", size = 22651571, upload-time = "2026-02-10T19:18:22.74Z" }, + { url = "https://files.pythonhosted.org/packages/e8/b8/e4f1dda1b3b0cc6c8ac06952bfe7bc28893ff016fb87651c8fafc6dfca96/uv-0.10.2-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9f878837938103ee1307ed3ed5d9228118e3932816ab0deb451e7e16dc8ce82a", size = 22321279, upload-time = "2026-02-10T19:17:49.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4b/baa16d46469e024846fc1a8aa0cfa63f1f89ad0fd3eaa985359a168c3fb0/uv-0.10.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6ec75cfe638b316b329474aa798c3988e5946ead4d9e977fe4dc6fc2ea3e0b8b", size = 23252208, upload-time = "2026-02-10T19:17:54.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/6a74e5ec2ee90e4314905e6d1d1708d473e06405e492ec38868b42645388/uv-0.10.2-py3-none-win32.whl", hash = "sha256:f7f3c7e09bf53b81f55730a67dd86299158f470dffb2bd279b6432feb198d231", size = 21118543, upload-time = "2026-02-10T19:18:07.296Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f9/e5cc6cf3a578b87004e857274df97d3cdecd8e19e965869b9b67c094c20c/uv-0.10.2-py3-none-win_amd64.whl", hash = "sha256:7b3685aa1da15acbe080b4cba8684afbb6baf11c9b04d4d4b347cc18b7b9cfa0", size = 23620790, upload-time = "2026-02-10T19:17:45.204Z" }, + { url = "https://files.pythonhosted.org/packages/df/7a/99979dc08ae6a65f4f7a44c5066699016c6eecdc4e695b7512c2efb53378/uv-0.10.2-py3-none-win_arm64.whl", hash = "sha256:abdd5b3c6b871b17bf852a90346eb7af881345706554fd082346b000a9393afd", size = 22035199, upload-time = "2026-02-10T19:18:03.679Z" }, ] [[package]] From d41bffd18b29aac384de2eff01b8e425ef449862 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 09:00:59 +0000 Subject: [PATCH 04/20] chore: remove calculate tool from realtime_with_tools sample - Two remaining tools (get_weather, get_time) are sufficient - Removes eval()-based code that was a copy/paste risk --- .../realtime/realtime_with_tools.py | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/python/samples/getting_started/realtime/realtime_with_tools.py b/python/samples/getting_started/realtime/realtime_with_tools.py index 1ab6cc8274..86041a7ea4 100644 --- a/python/samples/getting_started/realtime/realtime_with_tools.py +++ b/python/samples/getting_started/realtime/realtime_with_tools.py @@ -62,22 +62,6 @@ def get_time( return f"The current time in {timezone_name} is {current_time.strftime('%I:%M %p')}." -@tool -def calculate( - expression: Annotated[str, Field(description="A math expression like '2 + 2' or '10 * 5'")], -) -> str: - """Evaluate a simple math expression.""" - try: - # Simple eval for demo - in production use a safer parser - allowed_chars = set("0123456789+-*/(). ") - if all(c in allowed_chars for c in expression): - result = eval(expression) - return f"The result of {expression} is {result}" - return "Invalid expression" - except Exception: - return "Could not evaluate expression" - - def create_realtime_client(client_type: str) -> BaseRealtimeClient: """Create a realtime client based on the specified type.""" if client_type == "openai": @@ -124,11 +108,11 @@ async def main(client_type: str) -> None: realtime_client=client, name="ToolsAssistant", instructions="""You are a helpful voice assistant with access to tools. - You can check the weather, tell the time, and do calculations. + You can check the weather and tell the time. When asked about these topics, use your tools to provide accurate information. Keep your responses conversational and brief.""", voice="alloy", - tools=[get_weather, get_time, calculate], + tools=[get_weather, get_time], ) # 3. Set up audio I/O @@ -138,7 +122,6 @@ async def main(client_type: str) -> None: print("Available tools:") print(" - get_weather(location): Get weather for a city") print(" - get_time(timezone): Get current time") - print(" - calculate(expression): Evaluate math") print("\nStarting audio devices...") microphone.start() @@ -229,7 +212,6 @@ async def main(client_type: str) -> None: Available tools: - get_weather(location): Get weather for a city - get_time(timezone): Get current time - - calculate(expression): Evaluate math Starting audio devices... From 6adf02a54302547175702f8ee7369b778d0a618a Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 09:06:20 +0000 Subject: [PATCH 05/20] fix: preserve chronological message order in RealtimeAgent - Collect all messages in a single list instead of grouping by role - Store messages in transcript order (user, assistant, user, assistant) - Update test assertions to match chronological ordering --- .../core/agent_framework/_realtime_agent.py | 14 +++++++++----- .../core/tests/core/test_realtime_agent.py | 8 ++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/python/packages/core/agent_framework/_realtime_agent.py b/python/packages/core/agent_framework/_realtime_agent.py index e2faada658..ded7a20eb7 100644 --- a/python/packages/core/agent_framework/_realtime_agent.py +++ b/python/packages/core/agent_framework/_realtime_agent.py @@ -137,8 +137,7 @@ async def run( await self._client.connect(config) - input_messages: list[ChatMessage] = [] - response_messages: list[ChatMessage] = [] + all_messages: list[ChatMessage] = [] try: send_task = asyncio.create_task(self._send_audio_loop(audio_input)) @@ -148,11 +147,11 @@ async def run( if event.type == "input_transcript": text = event.data.get("text", "") if text: - input_messages.append(ChatMessage(role="user", text=text)) + all_messages.append(ChatMessage(role="user", text=text)) elif event.type == "response_transcript": text = event.data.get("text", "") if text: - response_messages.append(ChatMessage(role="assistant", text=text)) + all_messages.append(ChatMessage(role="assistant", text=text)) yield event finally: send_task.cancel() @@ -160,7 +159,12 @@ async def run( await send_task finally: await self._client.disconnect() - await self._notify_thread_of_new_messages(thread, input_messages, response_messages) + input_messages = [m for m in all_messages if m.role == "user"] + response_messages = [m for m in all_messages if m.role == "assistant"] + if all_messages: + await thread.on_new_messages(all_messages) + if thread.context_provider: + await thread.context_provider.invoked(input_messages, response_messages) async def _send_audio_loop(self, audio_input: AsyncIterator[bytes]) -> None: try: diff --git a/python/packages/core/tests/core/test_realtime_agent.py b/python/packages/core/tests/core/test_realtime_agent.py index 7144db0f19..a97e147672 100644 --- a/python/packages/core/tests/core/test_realtime_agent.py +++ b/python/packages/core/tests/core/test_realtime_agent.py @@ -521,9 +521,9 @@ async def empty_audio(): assert len(messages) == 4 assert messages[0].role == "user" assert messages[0].text == "What time is it?" - assert messages[1].role == "user" - assert messages[1].text == "Thanks!" - assert messages[2].role == "assistant" - assert messages[2].text == "It is 3 PM." + assert messages[1].role == "assistant" + assert messages[1].text == "It is 3 PM." + assert messages[2].role == "user" + assert messages[2].text == "Thanks!" assert messages[3].role == "assistant" assert messages[3].text == "You're welcome." From fb56e1c161cedf525491e98e6cd21ecf1d68e50a Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 09:07:36 +0000 Subject: [PATCH 06/20] chore: remove calculate tool from multi-agent realtime sample - Removes eval()-based calculate tool (same as realtime_with_tools) - Two remaining tools (get_weather, get_time) are sufficient --- .../realtime/realtime_with_multiple_agents.py | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/python/samples/getting_started/realtime/realtime_with_multiple_agents.py b/python/samples/getting_started/realtime/realtime_with_multiple_agents.py index 1b486b20ff..c3da5b1e55 100644 --- a/python/samples/getting_started/realtime/realtime_with_multiple_agents.py +++ b/python/samples/getting_started/realtime/realtime_with_multiple_agents.py @@ -65,21 +65,6 @@ def get_time( return f"The current time in {timezone_name} is {current_time.strftime('%I:%M %p')}." -@tool -def calculate( - expression: Annotated[str, Field(description="A math expression like '2 + 2' or '10 * 5'")], -) -> str: - """Evaluate a simple math expression.""" - try: - allowed_chars = set("0123456789+-*/(). ") - if all(c in allowed_chars for c in expression): - result = eval(expression) - return f"The result of {expression} is {result}" - return "Invalid expression" - except Exception: - return "Could not evaluate expression" - - @tool def lookup_order( order_id: Annotated[str, Field(description="The order ID to look up, e.g. 'ORD-1234'")], @@ -150,12 +135,12 @@ class AgentDefinition: name="assistant", display_name="Assistant", instructions=( - "You are a helpful assistant named Robin. You can check the weather, " - "tell the time, and do calculations. Be conversational and concise.\n\n" + "You are a helpful assistant named Robin. You can check the weather " + "and tell the time. Be conversational and concise.\n\n" "If the user needs help with an order or support issue, transfer to 'support'.\n" "If they want general conversation, transfer to 'greeter'." ), - tools=[get_weather, get_time, calculate], + tools=[get_weather, get_time], voice="echo", ), } From 3270ffd5aaeaceb79edb1b0b0f61870298cde0d3 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 09:09:45 +0000 Subject: [PATCH 07/20] fix: bound audio queue in VoiceSession to prevent unbounded memory growth - Set maxsize=100 on asyncio.Queue to apply backpressure - Prevents memory growth if client sends audio faster than consumed --- .../getting_started/realtime/realtime_fastapi_websocket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/samples/getting_started/realtime/realtime_fastapi_websocket.py b/python/samples/getting_started/realtime/realtime_fastapi_websocket.py index da4bdce283..d223671fb3 100644 --- a/python/samples/getting_started/realtime/realtime_fastapi_websocket.py +++ b/python/samples/getting_started/realtime/realtime_fastapi_websocket.py @@ -104,7 +104,7 @@ class VoiceSession: def __init__(self, websocket: WebSocket, agent: RealtimeAgent): self.websocket = websocket self.agent = agent - self._audio_queue: asyncio.Queue[bytes] = asyncio.Queue() + self._audio_queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=100) self._running = False async def audio_input_generator(self) -> AsyncIterator[bytes]: From 9e3c9172ebbd6f550ce0d217b2a43b0fb4fd9a89 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 09:11:57 +0000 Subject: [PATCH 08/20] fix: pass api_version to Voice Live SDK connect call - Thread stored _api_version through to vl_connect() - The SDK supports it; previously the setting was accepted but ignored - Update existing connect tests to assert default api_version - Add test for custom api_version forwarding --- .../_client.py | 1 + .../azure-ai-voice-live/tests/test_client.py | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_client.py b/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_client.py index 77172bda83..507c4f2158 100644 --- a/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_client.py +++ b/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_client.py @@ -172,6 +172,7 @@ async def connect(self, config: RealtimeSessionConfig) -> None: endpoint=self._endpoint, credential=credential, model=self._model, + api_version=self._api_version, ) self._connection = await self._connection_manager.__aenter__() diff --git a/python/packages/azure-ai-voice-live/tests/test_client.py b/python/packages/azure-ai-voice-live/tests/test_client.py index 5f2fdb316f..85bac3ec12 100644 --- a/python/packages/azure-ai-voice-live/tests/test_client.py +++ b/python/packages/azure-ai-voice-live/tests/test_client.py @@ -389,6 +389,7 @@ async def test_connect_uses_sdk(): endpoint="https://test.services.ai.azure.com", credential="mock-credential", model="gpt-4o-realtime-preview", + api_version="2025-10-01", ) mock_connection.session.update.assert_called_once() assert client.is_connected @@ -421,12 +422,48 @@ async def test_connect_with_credential(): endpoint="https://test.services.ai.azure.com", credential=mock_cred, model="gpt-4o-realtime-preview", + api_version="2025-10-01", ) assert client.is_connected await client.disconnect() +@pytest.mark.asyncio +async def test_connect_passes_custom_api_version(): + """Connect forwards a custom api_version to the Voice Live SDK.""" + client = AzureVoiceLiveClient( + endpoint="https://test.services.ai.azure.com", + api_key="test-key", + model="gpt-4o-realtime-preview", + api_version="2026-01-15", + ) + + mock_connection = AsyncMock() + mock_connection.session = MagicMock() + mock_connection.session.update = AsyncMock() + + mock_manager = AsyncMock() + mock_manager.__aenter__ = AsyncMock(return_value=mock_connection) + mock_manager.__aexit__ = AsyncMock(return_value=False) + + with ( + patch("agent_framework_azure_voice_live._client.vl_connect", return_value=mock_manager) as mock_connect, + patch("agent_framework_azure_voice_live._client.AzureKeyCredential") as MockCred, + ): + MockCred.return_value = "mock-credential" + await client.connect(RealtimeSessionConfig(instructions="Be helpful")) + + mock_connect.assert_called_once_with( + endpoint="https://test.services.ai.azure.com", + credential="mock-credential", + model="gpt-4o-realtime-preview", + api_version="2026-01-15", + ) + + await client.disconnect() + + @pytest.mark.asyncio async def test_connect_no_auth_raises(): """Connect raises ServiceInitializationError when no credential is available.""" From 0b07834696d92d19a1dfd1313f98a67461fb3767 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 09:13:02 +0000 Subject: [PATCH 09/20] test: add missing event types to realtime types test - Add input_transcript, response_transcript, and tool_result - Reflects the full normalized event surface produced by clients --- python/packages/core/tests/core/test_realtime_types.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/packages/core/tests/core/test_realtime_types.py b/python/packages/core/tests/core/test_realtime_types.py index b660a547ec..a115d681ad 100644 --- a/python/packages/core/tests/core/test_realtime_types.py +++ b/python/packages/core/tests/core/test_realtime_types.py @@ -15,7 +15,10 @@ def test_realtime_event_types(): event_types = [ "audio", "transcript", + "input_transcript", + "response_transcript", "tool_call", + "tool_result", "interrupted", "error", "session_update", From 084bb43d5322b67e5b0dbc42e7de0c0ab582cddf Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 09:17:19 +0000 Subject: [PATCH 10/20] fix: return clear error for non-FunctionTool in realtime execute_tool - Non-FunctionTool entries now return an explicit error message - Previously fell back to str(tool), sending misleading results to model - Add test covering non-FunctionTool execution path --- .../core/agent_framework/_realtime_agent.py | 2 +- .../core/tests/core/test_realtime_agent.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_realtime_agent.py b/python/packages/core/agent_framework/_realtime_agent.py index ded7a20eb7..668d8bc8b4 100644 --- a/python/packages/core/agent_framework/_realtime_agent.py +++ b/python/packages/core/agent_framework/_realtime_agent.py @@ -75,7 +75,7 @@ async def execute_tool( if isinstance(tool, FunctionTool): result = await tool.invoke(**arguments) else: - result = str(tool) + return f"Tool '{tool_name}' is not a FunctionTool and cannot be executed in realtime sessions." return str(result) except Exception as e: return f"Error executing {tool_name}: {e}" diff --git a/python/packages/core/tests/core/test_realtime_agent.py b/python/packages/core/tests/core/test_realtime_agent.py index a97e147672..b8c096150e 100644 --- a/python/packages/core/tests/core/test_realtime_agent.py +++ b/python/packages/core/tests/core/test_realtime_agent.py @@ -527,3 +527,19 @@ async def empty_audio(): assert messages[2].text == "Thanks!" assert messages[3].role == "assistant" assert messages[3].text == "You're welcome." + + +@pytest.mark.asyncio +async def test_execute_tool_non_function_tool_returns_error(): + """Non-FunctionTool entries return a clear error instead of str(tool).""" + from unittest.mock import MagicMock + + from agent_framework._realtime_agent import execute_tool + + mock_tool = MagicMock() + mock_tool.name = "not_a_function" + registry = {"not_a_function": mock_tool} + + result = await execute_tool(registry, {"name": "not_a_function", "arguments": "{}"}) + assert "not a FunctionTool" in result + assert "not_a_function" in result From 3acd603b873457e6f941a32595fe9c7f01b65168 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 09:20:19 +0000 Subject: [PATCH 11/20] fix: remove unnecessary name assignment in websocket_audio_client - Rename to tool_name in tool_call branch to avoid shadowing builtin - Remove unused name assignment in tool_result branch --- .../getting_started/realtime/websocket_audio_client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/python/samples/getting_started/realtime/websocket_audio_client.py b/python/samples/getting_started/realtime/websocket_audio_client.py index 120d2412fe..1424b7bad0 100644 --- a/python/samples/getting_started/realtime/websocket_audio_client.py +++ b/python/samples/getting_started/realtime/websocket_audio_client.py @@ -180,13 +180,12 @@ async def _receive_events(ws, player: AudioPlayer) -> None: ai_speaking = False elif msg_type == "tool_call": - name = msg.get("name", "unknown") + tool_name = msg.get("name", "unknown") args = msg.get("arguments", "{}") - print(f"\n>>> Tool called: {name}") + print(f"\n>>> Tool called: {tool_name}") print(f" Arguments: {args}") elif msg_type == "tool_result": - name = msg.get("name", "unknown") result = msg.get("result", "") print(f" Result: {result}") From b9c51e9b80054e6e7e59fbc943cc3987f3b813c0 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 09:22:25 +0000 Subject: [PATCH 12/20] fix: consolidate contextlib imports in fastapi websocket sample - Use single 'from contextlib import' instead of mixing import styles - Replace contextlib.suppress with suppress throughout --- .../realtime/realtime_fastapi_websocket.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/python/samples/getting_started/realtime/realtime_fastapi_websocket.py b/python/samples/getting_started/realtime/realtime_fastapi_websocket.py index d223671fb3..3c0f2838a2 100644 --- a/python/samples/getting_started/realtime/realtime_fastapi_websocket.py +++ b/python/samples/getting_started/realtime/realtime_fastapi_websocket.py @@ -2,11 +2,10 @@ import asyncio import base64 -import contextlib import json import os from collections.abc import AsyncIterator -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from typing import Annotated from agent_framework import BaseRealtimeClient, RealtimeAgent, tool @@ -159,7 +158,7 @@ async def run(self) -> None: finally: self._running = False receive_task.cancel() - with contextlib.suppress(asyncio.CancelledError): + with suppress(asyncio.CancelledError): await receive_task async def _receive_loop(self) -> None: @@ -288,10 +287,10 @@ async def websocket_voice_endpoint(websocket: WebSocket): print("Client disconnected") except Exception as e: print(f"Session error: {e}") - with contextlib.suppress(Exception): + with suppress(Exception): await websocket.send_json({"type": "error", "message": str(e)}) finally: - with contextlib.suppress(Exception): + with suppress(Exception): await websocket.close() From 4487ae40390dcf95473b9b725084657d49eb85e5 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 09:25:42 +0000 Subject: [PATCH 13/20] fix: replace bare pass comment with debug log in _send_audio_loop - Add logger to _realtime_agent.py using project get_logger convention - Log cancellation at debug level instead of silent pass --- python/packages/core/agent_framework/_realtime_agent.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_realtime_agent.py b/python/packages/core/agent_framework/_realtime_agent.py index 668d8bc8b4..da101bfef6 100644 --- a/python/packages/core/agent_framework/_realtime_agent.py +++ b/python/packages/core/agent_framework/_realtime_agent.py @@ -16,10 +16,13 @@ from agent_framework._types import ChatMessage from ._agents import BaseAgent +from ._logging import get_logger if TYPE_CHECKING: from agent_framework._realtime_client import RealtimeClientProtocol +logger = get_logger("agent_framework") + __all__ = ["RealtimeAgent", "execute_tool", "tool_to_schema"] @@ -171,7 +174,7 @@ async def _send_audio_loop(self, audio_input: AsyncIterator[bytes]) -> None: async for chunk in audio_input: await self._client.send_audio(chunk) except asyncio.CancelledError: - pass + logger.debug("Audio send loop cancelled — agent stopped receiving events.") async def _process_events(self) -> AsyncIterator[RealtimeEvent]: async for event in self._client.events(): From 633c490ed26c03e2d78fed14e3e1aef02a722e71 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 09:29:45 +0000 Subject: [PATCH 14/20] fix: replace bare except pass with debug logging in realtime samples - Add logging to realtime_fastapi_websocket, realtime_with_multiple_agents, and websocket_audio_client samples - Log cancellation/disconnect at debug level instead of silent pass --- .../getting_started/realtime/realtime_fastapi_websocket.py | 5 ++++- .../realtime/realtime_with_multiple_agents.py | 5 ++++- .../getting_started/realtime/websocket_audio_client.py | 7 +++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/python/samples/getting_started/realtime/realtime_fastapi_websocket.py b/python/samples/getting_started/realtime/realtime_fastapi_websocket.py index 3c0f2838a2..5a38ad0892 100644 --- a/python/samples/getting_started/realtime/realtime_fastapi_websocket.py +++ b/python/samples/getting_started/realtime/realtime_fastapi_websocket.py @@ -3,6 +3,7 @@ import asyncio import base64 import json +import logging import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager, suppress @@ -15,6 +16,8 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect from pydantic import Field +logger = logging.getLogger(__name__) + """ Realtime Voice Agent with FastAPI WebSocket @@ -170,7 +173,7 @@ async def _receive_loop(self) -> None: except WebSocketDisconnect: self._running = False except asyncio.CancelledError: - pass + logger.debug("WebSocket receive loop cancelled.") async def _handle_agent_event(self, event) -> None: """Forward RealtimeAgent events to the WebSocket client.""" diff --git a/python/samples/getting_started/realtime/realtime_with_multiple_agents.py b/python/samples/getting_started/realtime/realtime_with_multiple_agents.py index c3da5b1e55..610e7d88d4 100644 --- a/python/samples/getting_started/realtime/realtime_with_multiple_agents.py +++ b/python/samples/getting_started/realtime/realtime_with_multiple_agents.py @@ -3,6 +3,7 @@ import argparse import asyncio import contextlib +import logging import os from dataclasses import dataclass from datetime import datetime, timezone @@ -16,6 +17,8 @@ from azure.identity import DefaultAzureCredential from pydantic import Field +logger = logging.getLogger(__name__) + """ Realtime Voice Agent with Multiple Agents Example @@ -249,7 +252,7 @@ async def send_audio() -> None: async for chunk in microphone.audio_generator(): await client.send_audio(chunk) except asyncio.CancelledError: - pass + logger.debug("Audio send loop cancelled.") send_task = asyncio.create_task(send_audio()) diff --git a/python/samples/getting_started/realtime/websocket_audio_client.py b/python/samples/getting_started/realtime/websocket_audio_client.py index 1424b7bad0..a2d4977e62 100644 --- a/python/samples/getting_started/realtime/websocket_audio_client.py +++ b/python/samples/getting_started/realtime/websocket_audio_client.py @@ -4,10 +4,13 @@ import asyncio import base64 import json +import logging import sys from audio_utils import AudioPlayer, MicrophoneCapture, check_pyaudio +logger = logging.getLogger(__name__) + """ WebSocket Audio Client @@ -146,7 +149,7 @@ async def _send_audio(ws, microphone: MicrophoneCapture) -> None: }) await ws.send(message) except (asyncio.CancelledError, ConnectionClosedError, ConnectionClosedOK): - pass + logger.debug("Audio send loop stopped.") async def _receive_events(ws, player: AudioPlayer) -> None: @@ -204,7 +207,7 @@ async def _receive_events(ws, player: AudioPlayer) -> None: print("[Session ready]") except (asyncio.CancelledError, ConnectionClosedError, ConnectionClosedOK): - pass + logger.debug("Event receive loop stopped.") def parse_args() -> argparse.Namespace: From 07a860308ba19a886b895498f65bf11009bec9a0 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 09:48:49 +0000 Subject: [PATCH 15/20] chore: add PEP 723 inline script metadata to realtime samples - Declare external dependencies (pyaudio, fastapi, uvicorn, websockets) - Makes samples self-contained and runnable with uv run - Follows SAMPLE_GUIDELINES.md conventions --- .../realtime/realtime_fastapi_websocket.py | 10 ++++++++++ .../realtime/realtime_with_microphone.py | 8 ++++++++ .../realtime/realtime_with_multiple_agents.py | 8 ++++++++ .../getting_started/realtime/realtime_with_tools.py | 8 ++++++++ .../getting_started/realtime/websocket_audio_client.py | 9 +++++++++ 5 files changed, 43 insertions(+) diff --git a/python/samples/getting_started/realtime/realtime_fastapi_websocket.py b/python/samples/getting_started/realtime/realtime_fastapi_websocket.py index 5a38ad0892..a397be36a4 100644 --- a/python/samples/getting_started/realtime/realtime_fastapi_websocket.py +++ b/python/samples/getting_started/realtime/realtime_fastapi_websocket.py @@ -1,3 +1,13 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "fastapi", +# "uvicorn", +# "websockets", +# ] +# /// +# Run with: uv run samples/getting_started/realtime/realtime_fastapi_websocket.py + # Copyright (c) Microsoft. All rights reserved. import asyncio diff --git a/python/samples/getting_started/realtime/realtime_with_microphone.py b/python/samples/getting_started/realtime/realtime_with_microphone.py index 5d3d2fc5bd..83a1cb3b37 100644 --- a/python/samples/getting_started/realtime/realtime_with_microphone.py +++ b/python/samples/getting_started/realtime/realtime_with_microphone.py @@ -1,3 +1,11 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "pyaudio", +# ] +# /// +# Run with: uv run samples/getting_started/realtime/realtime_with_microphone.py + # Copyright (c) Microsoft. All rights reserved. import argparse diff --git a/python/samples/getting_started/realtime/realtime_with_multiple_agents.py b/python/samples/getting_started/realtime/realtime_with_multiple_agents.py index 610e7d88d4..c8a4148189 100644 --- a/python/samples/getting_started/realtime/realtime_with_multiple_agents.py +++ b/python/samples/getting_started/realtime/realtime_with_multiple_agents.py @@ -1,3 +1,11 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "pyaudio", +# ] +# /// +# Run with: uv run samples/getting_started/realtime/realtime_with_multiple_agents.py + # Copyright (c) Microsoft. All rights reserved. import argparse diff --git a/python/samples/getting_started/realtime/realtime_with_tools.py b/python/samples/getting_started/realtime/realtime_with_tools.py index 86041a7ea4..477557b3fa 100644 --- a/python/samples/getting_started/realtime/realtime_with_tools.py +++ b/python/samples/getting_started/realtime/realtime_with_tools.py @@ -1,3 +1,11 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "pyaudio", +# ] +# /// +# Run with: uv run samples/getting_started/realtime/realtime_with_tools.py + # Copyright (c) Microsoft. All rights reserved. import argparse diff --git a/python/samples/getting_started/realtime/websocket_audio_client.py b/python/samples/getting_started/realtime/websocket_audio_client.py index a2d4977e62..0271e7b6df 100644 --- a/python/samples/getting_started/realtime/websocket_audio_client.py +++ b/python/samples/getting_started/realtime/websocket_audio_client.py @@ -1,3 +1,12 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "pyaudio", +# "websockets", +# ] +# /// +# Run with: uv run samples/getting_started/realtime/websocket_audio_client.py + # Copyright (c) Microsoft. All rights reserved. import argparse From d75fc6a580cd7fe840fc272ff4e02833f6666a07 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 10:37:02 +0000 Subject: [PATCH 16/20] fix: handle missing id in tool_call events gracefully - Use get("id") instead of ["id"] to avoid KeyError - Log error and emit error event when id is missing - Skip send_tool_result when no id is available --- .../core/agent_framework/_realtime_agent.py | 10 +++++++- .../core/tests/core/test_realtime_agent.py | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_realtime_agent.py b/python/packages/core/agent_framework/_realtime_agent.py index da101bfef6..15a60bb572 100644 --- a/python/packages/core/agent_framework/_realtime_agent.py +++ b/python/packages/core/agent_framework/_realtime_agent.py @@ -179,8 +179,16 @@ async def _send_audio_loop(self, audio_input: AsyncIterator[bytes]) -> None: async def _process_events(self) -> AsyncIterator[RealtimeEvent]: async for event in self._client.events(): if event.type == "tool_call": + call_id = event.data.get("id") + if not call_id: + logger.error("Tool call event missing 'id', skipping: %s", event.data) + yield RealtimeEvent( + type="error", + data={"message": "Tool call event missing 'id'"}, + ) + continue result = await self._execute_tool(event.data) - await self._client.send_tool_result(event.data["id"], result) + await self._client.send_tool_result(call_id, result) yield event yield RealtimeEvent( type="tool_result", diff --git a/python/packages/core/tests/core/test_realtime_agent.py b/python/packages/core/tests/core/test_realtime_agent.py index b8c096150e..52ce9b1ab0 100644 --- a/python/packages/core/tests/core/test_realtime_agent.py +++ b/python/packages/core/tests/core/test_realtime_agent.py @@ -543,3 +543,27 @@ async def test_execute_tool_non_function_tool_returns_error(): result = await execute_tool(registry, {"name": "not_a_function", "arguments": "{}"}) assert "not a FunctionTool" in result assert "not_a_function" in result + + +@pytest.mark.asyncio +async def test_tool_call_without_id_emits_error_event(): + """Tool call events missing 'id' should emit an error event instead of crashing.""" + client = MockRealtimeClient( + events_to_yield=[ + RealtimeEvent(type="tool_call", data={"name": "get_weather", "arguments": '{"location": "NYC"}'}), + ] + ) + agent = RealtimeAgent(realtime_client=client, tools=[get_weather]) + + async def empty_audio(): + return + yield + + events = [] + async for event in agent.run(audio_input=empty_audio()): + events.append(event) + + assert len(events) == 1 + assert events[0].type == "error" + assert "missing 'id'" in events[0].data["message"] + assert len(client._tool_results) == 0 From c6700a6c3bb0636fded82484b7400c6eef598a97 Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 10:51:23 +0000 Subject: [PATCH 17/20] =?UTF-8?q?fix:=20update=20imports=20after=20main=20?= =?UTF-8?q?merge=20(ToolProtocol=20=E2=86=92=20FunctionTool,=20ChatMessage?= =?UTF-8?q?=20=E2=86=92=20Message)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace ToolProtocol with FunctionTool in _realtime_agent.py and _realtime_client.py - Replace ChatMessage with Message in _realtime_agent.py --- .../core/agent_framework/_realtime_agent.py | 20 +++++++++---------- .../core/agent_framework/_realtime_client.py | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/python/packages/core/agent_framework/_realtime_agent.py b/python/packages/core/agent_framework/_realtime_agent.py index 15a60bb572..638ad53279 100644 --- a/python/packages/core/agent_framework/_realtime_agent.py +++ b/python/packages/core/agent_framework/_realtime_agent.py @@ -12,8 +12,8 @@ from agent_framework._realtime_types import RealtimeEvent, RealtimeSessionConfig from agent_framework._threads import AgentThread -from agent_framework._tools import FunctionTool, ToolProtocol -from agent_framework._types import ChatMessage +from agent_framework._tools import FunctionTool +from agent_framework._types import Message from ._agents import BaseAgent from ._logging import get_logger @@ -26,7 +26,7 @@ __all__ = ["RealtimeAgent", "execute_tool", "tool_to_schema"] -def tool_to_schema(tool: ToolProtocol) -> dict[str, Any]: +def tool_to_schema(tool: FunctionTool) -> dict[str, Any]: """Convert a tool to a schema dict for realtime providers. Args: @@ -51,7 +51,7 @@ def tool_to_schema(tool: ToolProtocol) -> dict[str, Any]: async def execute_tool( - tool_registry: dict[str, ToolProtocol], + tool_registry: dict[str, FunctionTool], tool_call: dict[str, Any], ) -> str: """Execute a realtime tool call and return the string result. @@ -97,7 +97,7 @@ def __init__( id: str | None = None, name: str | None = None, description: str | None = None, - tools: list[ToolProtocol] | None = None, + tools: list[FunctionTool] | None = None, voice: str | None = None, **kwargs: Any, ) -> None: @@ -120,7 +120,7 @@ def __init__( self.instructions = instructions self._tools = tools or [] self.voice = voice - self._tool_registry: dict[str, ToolProtocol] = {t.name: t for t in self._tools} + self._tool_registry: dict[str, FunctionTool] = {t.name: t for t in self._tools} async def run( self, @@ -140,7 +140,7 @@ async def run( await self._client.connect(config) - all_messages: list[ChatMessage] = [] + all_messages: list[Message] = [] try: send_task = asyncio.create_task(self._send_audio_loop(audio_input)) @@ -150,11 +150,11 @@ async def run( if event.type == "input_transcript": text = event.data.get("text", "") if text: - all_messages.append(ChatMessage(role="user", text=text)) + all_messages.append(Message(role="user", text=text)) elif event.type == "response_transcript": text = event.data.get("text", "") if text: - all_messages.append(ChatMessage(role="assistant", text=text)) + all_messages.append(Message(role="assistant", text=text)) yield event finally: send_task.cancel() @@ -215,6 +215,6 @@ def as_tool(self, **kwargs: Any) -> Any: "Use ChatAgent for text-based multi-agent workflows." ) - def _tool_to_schema(self, tool: ToolProtocol) -> dict[str, Any]: + def _tool_to_schema(self, tool: FunctionTool) -> dict[str, Any]: """Convert a tool to a schema dict for the provider.""" return tool_to_schema(tool) diff --git a/python/packages/core/agent_framework/_realtime_client.py b/python/packages/core/agent_framework/_realtime_client.py index 183ff9ba01..daed6dc3b3 100644 --- a/python/packages/core/agent_framework/_realtime_client.py +++ b/python/packages/core/agent_framework/_realtime_client.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable from agent_framework._realtime_types import RealtimeEvent, RealtimeSessionConfig -from agent_framework._tools import ToolProtocol +from agent_framework._tools import FunctionTool from ._serialization import SerializationMixin @@ -165,7 +165,7 @@ def as_agent( name: str | None = None, description: str | None = None, instructions: str | None = None, - tools: list[ToolProtocol] | None = None, + tools: list[FunctionTool] | None = None, voice: str | None = None, **kwargs: Any, ) -> RealtimeAgent: From fe364530b5cfa70dfe2a8cda3fded4c71938906d Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 11:13:27 +0000 Subject: [PATCH 18/20] Fix samples link for realtime Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- python/packages/azure-ai-voice-live/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/azure-ai-voice-live/README.md b/python/packages/azure-ai-voice-live/README.md index 0bca939e85..93a0d860b8 100644 --- a/python/packages/azure-ai-voice-live/README.md +++ b/python/packages/azure-ai-voice-live/README.md @@ -44,4 +44,4 @@ async for event in agent.run(audio_input=microphone_stream()): play_audio(event.data["audio"]) ``` -For more examples, see the [Azure Voice Live examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/azure_ai/). +For more examples, see the [Azure Voice Live examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/realtime/). From f1f9eed2c43d5e48c103eedfa8596ed1a42be13e Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 11:19:36 +0000 Subject: [PATCH 19/20] Remove mention of maths questions --- python/samples/getting_started/realtime/realtime_with_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/samples/getting_started/realtime/realtime_with_tools.py b/python/samples/getting_started/realtime/realtime_with_tools.py index 477557b3fa..72ce3c3102 100644 --- a/python/samples/getting_started/realtime/realtime_with_tools.py +++ b/python/samples/getting_started/realtime/realtime_with_tools.py @@ -137,7 +137,7 @@ async def main(client_type: str) -> None: print("\n" + "=" * 50) print("Voice chat with tools is now active!") - print("Try asking about the weather, time, or math.") + print("Try asking about the weather or time.") print("Press Ctrl+C to end the conversation.") print("=" * 50 + "\n") From 88f0ed6fd4eb51b189c6620f5eb18268dc32e76e Mon Sep 17 00:00:00 2001 From: Adam Dougal Date: Wed, 11 Feb 2026 11:20:33 +0000 Subject: [PATCH 20/20] fix: align tool call output formatting in realtime_with_tools sample - Use indented single-line format consistent with realtime_with_multiple_agents --- .../samples/getting_started/realtime/realtime_with_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/samples/getting_started/realtime/realtime_with_tools.py b/python/samples/getting_started/realtime/realtime_with_tools.py index 72ce3c3102..1ff730ab0a 100644 --- a/python/samples/getting_started/realtime/realtime_with_tools.py +++ b/python/samples/getting_started/realtime/realtime_with_tools.py @@ -153,11 +153,11 @@ async def main(client_type: str) -> None: tool_name = event.data.get("name", "unknown") tool_args = event.data.get("arguments", "{}") print(f"\n>>> Tool called: {tool_name}") - print(f"Arguments:\n{tool_args}") + print(f" Arguments: {tool_args}") elif event.type == "tool_result": result = event.data.get("result", "") - print(f"Result:\n{result}") + print(f" Result: {result}") elif event.type == "listening": print("\n[You are speaking...]")