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..93a0d860b8 --- /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/realtime/). 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..507c4f2158 --- /dev/null +++ b/python/packages/azure-ai-voice-live/agent_framework_azure_voice_live/_client.py @@ -0,0 +1,411 @@ +# 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, + api_version=self._api_version, + ) + 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..85bac3ec12 --- /dev/null +++ b/python/packages/azure-ai-voice-live/tests/test_client.py @@ -0,0 +1,664 @@ +# 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", + api_version="2025-10-01", + ) + 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", + 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.""" + 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..638ad53279 --- /dev/null +++ b/python/packages/core/agent_framework/_realtime_agent.py @@ -0,0 +1,220 @@ +# 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 +from agent_framework._types import Message + +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"] + + +def tool_to_schema(tool: FunctionTool) -> 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, FunctionTool], + 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: + 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}" + + +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[FunctionTool] | 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, FunctionTool] = {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) + + all_messages: list[Message] = [] + + 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: + all_messages.append(Message(role="user", text=text)) + elif event.type == "response_transcript": + text = event.data.get("text", "") + if text: + all_messages.append(Message(role="assistant", text=text)) + yield event + finally: + send_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await send_task + finally: + await self._client.disconnect() + 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: + async for chunk in audio_input: + await self._client.send_audio(chunk) + except asyncio.CancelledError: + logger.debug("Audio send loop cancelled — agent stopped receiving events.") + + 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(call_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: 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 new file mode 100644 index 0000000000..daed6dc3b3 --- /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 FunctionTool + +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[FunctionTool] | 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..7c3b9d7a97 --- /dev/null +++ b/python/packages/core/agent_framework/azure/_realtime_client.py @@ -0,0 +1,389 @@ +# 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] + self._connected = True + + 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.""" + self._connected = False + self._pending_function_names.clear() + 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..3c38232072 --- /dev/null +++ b/python/packages/core/agent_framework/openai/_realtime_client.py @@ -0,0 +1,353 @@ +# 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] + self._connected = True + + 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.""" + self._connected = False + self._pending_function_names.clear() + 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..52ce9b1ab0 --- /dev/null +++ b/python/packages/core/tests/core/test_realtime_agent.py @@ -0,0 +1,569 @@ +# 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 == "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." + + +@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 + + +@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 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..a115d681ad --- /dev/null +++ b/python/packages/core/tests/core/test_realtime_types.py @@ -0,0 +1,88 @@ +# 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", + "input_transcript", + "response_transcript", + "tool_call", + "tool_result", + "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..a397be36a4 --- /dev/null +++ b/python/samples/getting_started/realtime/realtime_fastapi_websocket.py @@ -0,0 +1,378 @@ +# /// 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 +import base64 +import json +import logging +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress +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 + +logger = logging.getLogger(__name__) + +""" +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(maxsize=100) + 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 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: + logger.debug("WebSocket receive loop cancelled.") + + 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 suppress(Exception): + await websocket.send_json({"type": "error", "message": str(e)}) + finally: + with 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..83a1cb3b37 --- /dev/null +++ b/python/samples/getting_started/realtime/realtime_with_microphone.py @@ -0,0 +1,203 @@ +# /// 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 +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..c8a4148189 --- /dev/null +++ b/python/samples/getting_started/realtime/realtime_with_multiple_agents.py @@ -0,0 +1,472 @@ +# /// 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 +import asyncio +import contextlib +import logging +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 + +logger = logging.getLogger(__name__) + +""" +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 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 " + "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], + 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: + logger.debug("Audio send loop cancelled.") + + 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..1ff730ab0a --- /dev/null +++ b/python/samples/getting_started/realtime/realtime_with_tools.py @@ -0,0 +1,256 @@ +# /// 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 +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')}." + + +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 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], + ) + + # 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("\nStarting audio devices...") + microphone.start() + player.start() + + print("\n" + "=" * 50) + print("Voice chat with tools is now active!") + print("Try asking about the weather or time.") + 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: {tool_args}") + + elif event.type == "tool_result": + result = event.data.get("result", "") + print(f" Result: {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 + +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..0271e7b6df --- /dev/null +++ b/python/samples/getting_started/realtime/websocket_audio_client.py @@ -0,0 +1,263 @@ +# /// 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 +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 + +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): + logger.debug("Audio send loop stopped.") + + +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": + tool_name = msg.get("name", "unknown") + args = msg.get("arguments", "{}") + print(f"\n>>> Tool called: {tool_name}") + print(f" Arguments: {args}") + + elif msg_type == "tool_result": + 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): + logger.debug("Event receive loop stopped.") + + +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..54b6b06896 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" @@ -1000,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]] @@ -1043,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'" }, @@ -1052,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]] @@ -1324,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]] @@ -1356,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 = [ @@ -1673,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]] @@ -1835,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 = [ @@ -1853,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'" }, @@ -1862,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]] @@ -3059,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'" }, @@ -3075,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] @@ -3119,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]] @@ -3884,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'" }, @@ -3896,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]] @@ -4353,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]] @@ -4565,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 = [ @@ -5215,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 = [ @@ -6471,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]] @@ -6565,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]]