diff --git a/.azure-pipelines/hosted-agent-tests/python/bring-your-own/conversation_engine/foreground_background/test-spec.yml b/.azure-pipelines/hosted-agent-tests/python/bring-your-own/conversation_engine/foreground_background/test-spec.yml new file mode 100644 index 000000000..8d4509583 --- /dev/null +++ b/.azure-pipelines/hosted-agent-tests/python/bring-your-own/conversation_engine/foreground_background/test-spec.yml @@ -0,0 +1,27 @@ +# CE needs a dedicated WebSocket/audio client. The shared HTTP deployment runner +# skips this sample; planning as invocations validates raw assertions only. +version: 1 +sample: + owner: wujin + experiences: [azd] +tests: + - name: foreground-direct-response + turns: + - input: Say exactly "Ready for field operations." Do not start background work. + assertions: + - source: raw + type: contains + value: Ready for field operations. + - source: raw + type: regex + value: '"type"\s*:\s*"response\.done"' + - name: background-tool-result + turns: + - input: Find the fiber panel specifications for Quincy North using search_site_specs. + assertions: + - source: raw + type: regex + value: '(?i)OS2|OM4|Row C' + - source: raw + type: regex + value: '"type"\s*:\s*"response\.done"' diff --git a/.azure-pipelines/scripts/hosted-agent-samples-ci-skiplist b/.azure-pipelines/scripts/hosted-agent-samples-ci-skiplist index a4d07dd4e..c5de782f9 100644 --- a/.azure-pipelines/scripts/hosted-agent-samples-ci-skiplist +++ b/.azure-pipelines/scripts/hosted-agent-samples-ci-skiplist @@ -24,6 +24,7 @@ samples/csharp/hosted-agents/bring-your-own/conversation_engine/adapter_mode samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic samples/python/hosted-agents/bring-your-own/conversation_engine/adapter_mode samples/python/hosted-agents/bring-your-own/conversation_engine/basic +samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background samples/python/hosted-agents/bring-your-own/conversation_engine/langchain samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph diff --git a/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/README.md b/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/README.md index 888179eb4..e82d8636d 100644 --- a/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/README.md +++ b/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/README.md @@ -288,7 +288,9 @@ path. After deploying, verify readiness and a spoken echo through the declared V - The story and its word sequence are directly in `OnUserMessageAsync`; no model history is added. - Echo is scheduled as cancellable work so the receive loop remains available for control events. - Barge-in and cancellation synchronize caller-heard text and stop pending output. -- Playback state is bounded to 64 known responses and cleared on session termination. +- Playback state is bounded to 64 known responses and cleared on session termination. Each + heard prefix retains at most the 512 KiB UTF-8 output budget, without splitting Unicode + characters; the 8,000-character user-input limit does not truncate valid reply prefixes. - Custom spans contain identifiers, durations, and status, but no prompt or response content. - A deployed two-turn audio smoke passed with public SDK packages; see the [validation status](../README.md#validation-status). Perform deployment-specific validation diff --git a/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-dotnet/VoiceConnectionState.cs b/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-dotnet/VoiceConnectionState.cs index 38769d8cb..04e4736b4 100644 --- a/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-dotnet/VoiceConnectionState.cs +++ b/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-dotnet/VoiceConnectionState.cs @@ -39,6 +39,7 @@ internal sealed class VoiceConnectionState internal const int MaximumTerminalSends = 8; internal const int MaximumTrackedInputItems = 4096; internal const int MaximumTrackedPlaybackResponses = 64; + internal const int MaximumOutputBytes = 512 * 1024; private readonly object _sync = new(); private readonly HashSet _seenInputItemDigests = new(StringComparer.Ordinal); @@ -287,13 +288,14 @@ internal void RecordHeardText(string responseId, string heardText) } TrackResponse(responseId); var length = 0; - var characters = 0; + var bytes = 0; foreach (var rune in heardText.EnumerateRunes()) { - if (characters++ == 8_000) + if (bytes + rune.Utf8SequenceLength > MaximumOutputBytes) { break; } + bytes += rune.Utf8SequenceLength; length += rune.Utf16SequenceLength; } _heardTextByResponse[responseId] = heardText[..length]; diff --git a/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-dotnet/VoiceResponseCoordinator.cs b/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-dotnet/VoiceResponseCoordinator.cs index 530873bb0..9115ee7ef 100644 --- a/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-dotnet/VoiceResponseCoordinator.cs +++ b/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-dotnet/VoiceResponseCoordinator.cs @@ -337,7 +337,7 @@ await session.SendAsync(new VoiceResponseCreatedMessage( ThrowIfStopped(state, operation, cancellationToken); var chunk = iterator.Current; if (++outputChunks > 4096 || - (outputBytes += Encoding.UTF8.GetByteCount(chunk)) > 512 * 1024) + (outputBytes += Encoding.UTF8.GetByteCount(chunk)) > VoiceConnectionState.MaximumOutputBytes) { throw new InvalidOperationException("Response exceeds its output budget."); } @@ -418,7 +418,7 @@ await session.SendAsync(new VoiceResponseCreatedMessage( await Task.Yield(); await created; ThrowIfStopped(state, operation, cancellationToken); - if (Encoding.UTF8.GetByteCount(text) > 512 * 1024) + if (Encoding.UTF8.GetByteCount(text) > VoiceConnectionState.MaximumOutputBytes) { throw new InvalidOperationException("Response text exceeds its output budget."); } diff --git a/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-dotnet/tests/VoiceAgentTests.cs b/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-dotnet/tests/VoiceAgentTests.cs index 54211722b..566a5072a 100644 --- a/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-dotnet/tests/VoiceAgentTests.cs +++ b/samples/csharp/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-dotnet/tests/VoiceAgentTests.cs @@ -1305,7 +1305,7 @@ await agent.SessionEndAsync(session, [TestCase(8_000)] [TestCase(8_001)] - public void HeardTextBudget_MatchesAcceptedUnicodeScalars(int count) + public void HeardTextBudget_DoesNotApplyInputLimit(int count) { var state = new VoiceConnectionState(); state.TryBeginStart(); @@ -1314,10 +1314,37 @@ public void HeardTextBudget_MatchesAcceptedUnicodeScalars(int count) Assert.That(state.TryReserveOperation(operation), Is.True); state.RecordHeardText("r_unicode", string.Concat(Enumerable.Repeat("👋", count))); Assert.That(state.TryGetHeardText("r_unicode", out var heard), Is.True); - Assert.That(heard, Is.EqualTo(string.Concat(Enumerable.Repeat("👋", 8_000)))); + Assert.That(heard, Is.EqualTo(string.Concat(Enumerable.Repeat("👋", count)))); Assert.That(state.TryRemoveOperation("r_unicode", out _), Is.True); } + [TestCase("x", 524_288)] + [TestCase("👋", 131_072)] + [TestCase("界", 174_762)] + public void HeardTextBudget_PreservesWholeRunesWithinOutputBytes(string unit, int count) + { + var state = new VoiceConnectionState(); + state.TryBeginStart(); + state.Activate(); + using var operation = new ResponseOperation("r_budget", "in_budget"); + Assert.That(state.TryReserveOperation(operation), Is.True); + var prefix = string.Concat(Enumerable.Repeat(unit, count)); + state.RecordHeardText("r_budget", prefix + unit); + Assert.That(state.TryGetHeardText("r_budget", out var heard), Is.True); + Assert.That(heard.Length, Is.EqualTo(prefix.Length)); + Assert.That(heard, Is.EqualTo(prefix)); + Assert.That(System.Text.Encoding.UTF8.GetByteCount(heard), Is.LessThanOrEqualTo(512 * 1024)); + state.RecordHeardText("r_unknown", prefix); + Assert.That(state.TryGetHeardText("r_unknown", out _), Is.False); + state.RecordHeardText("r_budget", string.Empty); + Assert.That(state.TryGetHeardText("r_budget", out heard), Is.True); + Assert.That(heard, Is.Empty); + state.RecordHeardText("r_budget", "retry"); + Assert.That(state.TryGetHeardText("r_budget", out heard), Is.True); + Assert.That(heard, Is.EqualTo("retry")); + Assert.That(state.TryRemoveOperation("r_budget", out _), Is.True); + } + private static TestableVoiceAgent CreateAgent() => new(NullLogger.Instance); diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/README.md b/samples/python/hosted-agents/bring-your-own/conversation_engine/README.md index b201d7daf..d1d0a0796 100644 --- a/samples/python/hosted-agents/bring-your-own/conversation_engine/README.md +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/README.md @@ -30,6 +30,7 @@ require no model call. | --- | --- | --- | | Python | [Voice Live — Adapter Mode](adapter_mode/) | Recommended for reusing an invocation handler: a CE-only Agent calls a streaming HTTP-shaped Echo handler in process. | | Python | [Voice Live Bridge — Basic Python](basic/) | A framework-free Python hosted agent using `azure-ai-agentserver-invocations`. | +| Python | [Foreground + Background Conversation Engine](foreground_background/) | Model-backed foreground Router and background tool Worker; CE counterpart to the retained Responses VoiceLive sample. | | Python | [Voice Live Bridge — LangChain Python](langchain/) | Model-free async story stream; legacy directory/Agent names retained. | | Python | [Voice Live Bridge — LangGraph Python](langgraph/) | Model-free async story stream; legacy directory/Agent names retained. | | C# | [Voice Live — Adapter Mode](../../../../csharp/hosted-agents/bring-your-own/conversation_engine/adapter_mode/) | The equivalent in-process adapter and streaming Echo handler for .NET. | @@ -38,6 +39,11 @@ require no model call. All implementations demonstrate the same Bridge Protocol behavior so you can start with the language that matches your application. +The foreground/background sample differs from the model-free story/Echo samples: it retains +its Foundry model deployment, Router meta-tools, and mock domain tools. Worker tasks and bounded +conversation history are isolated per physical connection. See its README for interruption, +delivery, shutdown, and deployment prerequisites. + ## Choose an integration style - **Adapter mode:** start here to reuse HTTP invocation logic without implementing Bridge @@ -49,6 +55,7 @@ language that matches your application. - **Native CE mode:** use `basic`, `langchain`, or `langgraph` when you want to implement typed Voice callbacks directly and manage the application-owned protocol state. These existing samples retain their fixed story behavior; the legacy variant names do not add framework clients. + Use `foreground_background` for the model-backed Router/Worker version of native CE. Adapter mode does not take over a replacement handler's business history or roll back side effects. Its Echo is stateless; persistence and any reconciliation of unheard text remain business concerns. @@ -61,7 +68,7 @@ Caller audio → Voice Live (STT, VAD, TTS, and barge-in) → Bridge Protocol 1.0 over voice_conversation_engine → hosted text agent - → Echo text (adapter mode) or a thirty-word story (native CE) streamed back to Voice Live + → Echo/story output, or model-backed Router/Worker output, streamed back to Voice Live ``` The caller connects to a Foundry **voice agent**, not directly to the hosted text agent. The voice @@ -86,6 +93,8 @@ protocol and is versioned independently from the Hosted Agent WebSocket ingress. Adapter mode echoes each valid input verbatim in streaming chunks, preserving whitespace and Unicode. Native CE samples stream the same thirty-word story for every valid input. Slash-prefixed text such as `/help` or `/end` is ordinary text, not a command, in both styles. +The foreground/background variant instead routes the caller's text to its model-backed business +workflow; it does not produce the fixed story or Echo reply. The samples demonstrate the Protocol `1.0` customer surface: diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/adapter_mode/src/voice-live-adapter-python/adapter/response_coordinator.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/adapter_mode/src/voice-live-adapter-python/adapter/response_coordinator.py index 9f9e7c5c8..564ab7de5 100644 --- a/samples/python/hosted-agents/bring-your-own/conversation_engine/adapter_mode/src/voice-live-adapter-python/adapter/response_coordinator.py +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/adapter_mode/src/voice-live-adapter-python/adapter/response_coordinator.py @@ -386,6 +386,8 @@ async def wait_for_detached_tasks(self) -> None: *(asyncio.shield(task) for task in tuple(self._detached_tasks)), return_exceptions=True, ) + # gather() can finish synchronously; let ownership-release callbacks run. + await asyncio.sleep(0) def schedule_none( self, session: Session, state: SessionState, input_item_id: str, diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/adapter_mode/src/voice-live-adapter-python/tests/test_shutdown_checkpoint.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/adapter_mode/src/voice-live-adapter-python/tests/test_shutdown_checkpoint.py new file mode 100644 index 000000000..477d9ac9f --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/adapter_mode/src/voice-live-adapter-python/tests/test_shutdown_checkpoint.py @@ -0,0 +1,17 @@ +"""Drain must progress when all retained tasks finished before the join.""" + +import asyncio + +import pytest + +from adapter.response_coordinator import ResponseCoordinator + + +@pytest.mark.asyncio +async def test_drain_yields_to_completed_task_release_callback() -> None: + coordinator = ResponseCoordinator() + task = asyncio.create_task(asyncio.sleep(0)) + await task + coordinator._retain_task(task) + await asyncio.wait_for(coordinator.wait_for_detached_tasks(), 1) + assert not coordinator._detached_tasks diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/README.md b/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/README.md index 5d24e5f47..fb2900a2a 100644 --- a/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/README.md +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/README.md @@ -260,7 +260,9 @@ After deployment, verify readiness and a spoken echo through the Voice wrapper. `state.py` owns connection state and response/input-ID correlation. - `user.barge_in` and `response.cancelled` synchronize caller-heard text and cancel pending output. `response.timeout` cancels work selected by response ID or input IDs. -- Playback state is bounded to 64 known responses with up to 8,000 characters of heard text each. +- Playback state is bounded to 64 known responses, each with at most 512 KiB of UTF-8 heard text, + matching the response output budget rather than the 8,000-character input limit. Larger values + are cut only at complete Unicode characters; string/object overhead is additional to this budget. Session termination clears it together with pending work. - Response operations contain protocol bookkeeping, not `prompt` or `use_model` flags. - Runtime dependencies are locked in `uv.lock`; OpenAI, LangChain, and LangGraph clients diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/response_coordinator.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/response_coordinator.py index a2cf16ea2..5ccfd976b 100644 --- a/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/response_coordinator.py +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/response_coordinator.py @@ -31,6 +31,7 @@ from response_telemetry import ResponseTelemetry from state import ( + MAX_OUTPUT_BYTES, MAX_PENDING_PROACTIVE, MAX_TERMINAL_SENDS, ApplicationTurn, @@ -42,7 +43,6 @@ ) logger = logging.getLogger("voice_live_bridge") -MAX_OUTPUT_BYTES = 512 * 1024 MAX_OUTPUT_CHUNKS = 4096 OutputChunk = str | Mapping[str, Any] @@ -386,6 +386,8 @@ async def wait_for_detached_tasks(self) -> None: *(asyncio.shield(task) for task in tuple(self._detached_tasks)), return_exceptions=True, ) + # gather() can finish synchronously; let ownership-release callbacks run. + await asyncio.sleep(0) def schedule_none( self, session: Session, state: SessionState, input_item_id: str, diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/state.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/state.py index f7aa66878..8e14f52fa 100644 --- a/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/state.py +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/state.py @@ -18,6 +18,7 @@ MAX_TRACKED_PLAYBACK_RESPONSES = 64 MAX_MESSAGE_CHARACTERS = 8_000 +MAX_OUTPUT_BYTES = 512 * 1024 MAX_ACTIVE_RESPONSES = 8 MAX_TERMINAL_SENDS = 8 MAX_PENDING_PROACTIVE = 8 @@ -157,8 +158,12 @@ def record_heard_text(self, response_id: str, heard_text: str) -> None: """Synchronize only responses this connection has actually tracked.""" if response_id not in self.heard_text_by_response and response_id not in self.responses: return + # Playback describes response output, not user input. Bound both the UTF-8 + # payload and temporary allocation; malformed escapes must not block cancellation. + encoded = heard_text[:MAX_OUTPUT_BYTES].encode("utf-8", errors="replace") + bounded = encoded[:MAX_OUTPUT_BYTES].decode("utf-8", errors="ignore") self.track_response(response_id) - self.heard_text_by_response[response_id] = heard_text[:MAX_MESSAGE_CHARACTERS] + self.heard_text_by_response[response_id] = bounded @property def terminating(self) -> bool: diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/tests/test_heard_text_budget.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/tests/test_heard_text_budget.py new file mode 100644 index 000000000..2925d0a23 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/tests/test_heard_text_budget.py @@ -0,0 +1,79 @@ +"""Heard prefixes use the response byte budget, not the user-input limit.""" + +import pytest + +from response_coordinator import MAX_OUTPUT_BYTES +from state import MAX_MESSAGE_CHARACTERS, MAX_TRACKED_PLAYBACK_RESPONSES, SessionState + + +@pytest.mark.parametrize( + "unit,count,tail,expected_count", + [ + ("x", 7999, "", 7999), + ("x", 8000, "", 8000), + ("x", 8001, "", 8001), + ("x", 8011, "", 8011), + ("👋", 8001, "", 8001), + ("x", 524288, "", 524288), + ("x", 524289, "", 524288), + ("👋", 131072, "", 131072), + ("👋", 131073, "", 131072), + ("界", 174763, "", 174762), + ("x", 524287, "👋", 524287), + ], + ids=[ + "below-input-limit", + "at-input-limit", + "above-input-limit", + "result-plus-ack", + "long-unicode", + "exact-byte-budget", + "over-byte-budget", + "unicode-exact-budget", + "unicode-over-budget", + "three-byte-boundary", + "partial-rune-must-not-be-retained", + ], +) +def test_heard_prefix_is_complete_within_output_budget_and_bounded_above_it( + unit: str, + count: int, + tail: str, + expected_count: int, +) -> None: + state = SessionState() + state.track_response("r_known") + heard = unit * count + tail + state.record_heard_text("r_known", heard) + stored = state.heard_text_by_response["r_known"] + assert len(stored) == len(unit * expected_count) + assert stored == unit * expected_count + assert heard.startswith(stored) + assert len(stored.encode("utf-8")) <= 512 * 1024 + if len(heard.encode("utf-8")) <= 512 * 1024: + assert stored == heard + state.record_heard_text("r_known", "") + assert state.heard_text_by_response["r_known"] == "" + state.record_heard_text("r_known", "retry prefix") + assert state.heard_text_by_response == {"r_known": "retry prefix"} + + +def test_unknown_and_evicted_responses_cannot_repopulate_playback_cache() -> None: + state = SessionState() + state.record_heard_text("r_unknown", "x" * 8011) + assert not state.heard_text_by_response + for index in range(MAX_TRACKED_PLAYBACK_RESPONSES + 1): + state.track_response(f"r_{index}") + state.record_heard_text("r_0", "evicted prefix") + assert "r_0" not in state.heard_text_by_response + assert len(state.heard_text_by_response) == 64 + state.record_heard_text("r_64", "x" * 8011) + assert len(state.heard_text_by_response["r_64"]) == 8011 + assert sum(len(text.encode("utf-8")) for text in state.heard_text_by_response.values()) <= ( + 64 * 512 * 1024 + ) + + +def test_response_budget_does_not_expand_user_input_or_output_limits() -> None: + assert MAX_MESSAGE_CHARACTERS == 8000 + assert MAX_OUTPUT_BYTES == 512 * 1024 diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/tests/test_shutdown_checkpoint.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/tests/test_shutdown_checkpoint.py new file mode 100644 index 000000000..b79a71b1a --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/basic/src/voice-live-bridge-basic-python/tests/test_shutdown_checkpoint.py @@ -0,0 +1,17 @@ +"""Drain must progress when all retained tasks finished before the join.""" + +import asyncio + +import pytest + +from response_coordinator import ResponseCoordinator + + +@pytest.mark.asyncio +async def test_drain_yields_to_completed_task_release_callback() -> None: + coordinator = ResponseCoordinator() + task = asyncio.create_task(asyncio.sleep(0)) + await task + coordinator._retain_task(task) + await asyncio.wait_for(coordinator.wait_for_detached_tasks(), 1) + assert not coordinator._detached_tasks diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/AGENTS.md b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/AGENTS.md new file mode 100644 index 000000000..281357580 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/AGENTS.md @@ -0,0 +1,47 @@ +# Coding Agent Instructions — Foreground + Background Conversation Engine + +This is the model-backed CE counterpart to the retained Responses sample. It uses Hosted protocol +`voice_conversation_engine:1.0.0` and Bridge message version `1.0`. + +## Scope and implementation + +- Preserve the Router, Worker, four mock domain tools, and model deployment. This is not a + model-free story/Echo sample. Do not copy the neighboring samples' business handler here. +- Keep the sample self-contained. The native CE response coordinator, state, and telemetry were + copied from Basic; do not introduce sibling imports, local wheels, or private feeds. +- `conversation_runtime.py` adapts business output to the existing native CE lifecycle. +- History and task stores are per physical connection. Record history after successful sends + and reconcile `heard_text`; do not commit generated-but-unsent text. +- Keep `heard_text` within the response output budget, not the user-input character limit; + history must use the same bounded value as playback state without splitting Unicode characters. +- Track delivered task IDs explicitly, including paraphrases, and commit only after + `response.done` sends successfully. Do not infer delivery from text substrings. +- Validate Router delivery claims against complete result bodies in its request snapshot, + never task-store state after model I/O. Index-only results require explicit retrieval; + concurrent Worker completion must still use an actual result-output path. +- Response interruption leaves business Workers running within the connection. End/disconnect + requests cooperative cancellation; shutdown retains and drains in-flight Workers before + closing clients. Do not cancel only an executor wrapper and call that drained cleanup. +- Keep receive callbacks nonblocking. Signal cancellation before awaiting terminal I/O. +- Preserve `user.barge_in`, timeouts, IDs, response limits, and terminal ownership. +- Do not forward raw upstream exceptions, prompts, model output, or credentials into logs/errors. +- The mock tools intentionally simulate latency. Tests replace that latency, not deployment code. + +## Development and dependencies + +Follow the parent Hosted Agent dependency policy. Regenerate `uv.lock` with dependency changes. +Use `uv sync --frozen`, pytest, Ruff, and compile/import checks declared in `sample.yaml`. +Do not configure cloud resources or run model/audio deployment tests without authorization. +The shared HTTP runner cannot exercise CE; use the documented dedicated WebSocket smoke client. +Do not claim fake-model tests validate live model permissions or deployed audio. + +## Deployment + +`azure.yaml` uses direct code deployment on Python 3.13 and also declares a managed Voice wrapper. +Keep the runtime directory and model deployment; initialize azd from a fresh directory. +Use distinct CE Hosted Agent and Voice wrapper names so the original Responses Agent is not +replaced when both samples are deployed in the same project. Leave the original sample unchanged. +Neither deprecated Bridge metadata key is needed. The SDK's independent legacy WS route remains +available, but only CE is declared. Agent Inspector and `azd ai agent invoke` are not CE clients. +Keep the model/identity and service-side CE rollout prerequisites explicit in the README. +Do not add generated `.vscode` files. diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/CLAUDE.md b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/CLAUDE.md new file mode 100644 index 000000000..f96352fea --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses [AGENTS.md](./AGENTS.md) as the single source of truth for coding agent instructions. The import below loads it into Claude Code's context. + +@AGENTS.md diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/README.md b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/README.md new file mode 100644 index 000000000..417947a37 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/README.md @@ -0,0 +1,231 @@ + +**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency note for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note). + +Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct. + +Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates. + +Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output. + + + +# Foreground + Background Conversation Engine + +## What this sample demonstrates + +This is a CE variant of the model-backed foreground/background VoiceLive sample, hosted on the +dedicated `voice_conversation_engine:1.0.0` protocol. The +[original Responses sample](../../voicelive/foreground-background-agents-responses-voicelive/) +remains unchanged at its original path. This CE variant does not expose a Responses HTTP endpoint. +The Router still uses Chat Completions, and the Worker still uses the model's Responses API; +those upstream model APIs are independent of the Hosted Agent ingress protocol. + +The foreground Router makes one asynchronous model request and selects one of +`respond_directly`, `start_task`, `check_task_status`, `cancel_task`, or `get_task_result`. +The background Worker retains the five-round tool loop and the four original domain tools: +`search_site_specs`, `search_work_iq`, `get_repair_procedure`, and `analyze_document`. +These tools return mock data and simulate 6–12 seconds of service latency. They do not connect +to real Work IQ, document-processing, or procedural-memory services. +Only one business Worker runs per connection. While it is busy, another `start_task` request +is explicitly declined, not queued or acknowledged as started; retry after the current work ends. +Worker model continuations preserve all returned output items, including encrypted reasoning, +in their original order before appending tool results. + +Voice Live owns speech recognition, turn detection, synthesis, and playback. The hosted +conversation engine handles text and control events. Router latency depends on the model; +there is no guaranteed two-second response time. + +## How it works + +- `main.py` creates the clients and starts the typed AgentServer Voice host. +- `agent_logic.py` retains Router dispatch, acknowledgments, periodic status messages, and Worker + result delivery. Optional status requests have a one-second deadline and run at most five times. +- `router_agent.py`, `worker_agent.py`, and `task_store.py` contain the original business tools. + Router calls disable parallel tool calls and validate exactly one returned call. +- `conversation_runtime.py` connects that workflow to the native CE runtime. It records only + successfully sent text, reconciles caller-heard text, and owns connection-scoped business state. +- `voice_runtime.py`, `response_coordinator.py`, `state.py`, and `response_telemetry.py` reuse the + existing Basic CE lifecycle. The local runtime's user-message hook delegates to the Router/Worker + subclass, rather than producing the Basic sample's story. There are no sibling-path imports. + +The manifest declares only `voice_conversation_engine:1.0.0`. The WebSocket route is +`/voice_conversation_engine`; the message handshake remains Bridge Protocol `1.0`. +Neither `voiceLiveCompatible` nor `bridgeProtocolVersion` metadata is required. +The SDK retains its independent `/invocations_ws` compatibility route, but the manifest does not +declare it. The sample accepts text only; application-data input is declined explicitly. + +### Interruption, history, and tasks + +Response generation runs outside receive callbacks, so the SDK can process `user.barge_in`, +`response.cancelled`, `response.timeout`, and `session.end` while model work is pending. +Response/input correlation, output budgets, duplicate-input rejection, and terminal sequencing +come from the existing CE coordinator. + +Tasks and the last 20 history entries belong to one physical connection, not a process-wide +store. History advances after successful text sends; `heard_text` updates replace an interrupted +assistant entry before a later Router request uses it. Queued turns build history when they +actually start, not when they are enqueued. +Caller-heard prefixes use the existing 512 KiB UTF-8 response budget, not the user-input +character limit. History and playback state use the same bounded prefix; over-budget values +are cut only at complete Unicode characters. This does not expand the model-history budget. +Router requests and new Workers share a recent-history snapshot capped at 16,000 content +characters. Oldest entries are dropped whole until the remaining chronological suffix fits; +individual entries are never clipped and the recorded history is unchanged. Current input and +system instructions are retained separately; task context has its own 8,000-character budget. +These are character limits, not model-token estimates or a guarantee for every deployment's +context window. Worker tool-round continuation is separate from this initial-history budget. +Playback corrections for entries already outside that history window do not reinsert old speech +as the newest reply. A correction can add a missing entry only during its first text send. +The first two no-input reminders use the same recorder, including their non-streaming +`response.output_text.done` text and caller-heard corrections. Streamed text is not counted twice. + +Barge-in or a response timeout stops foreground speech/waiting without cancelling the business +Worker. Its result can be delivered by a later turn on the same connection. Session end, +disconnect, or the third no-input event requests cooperative Worker cancellation before terminal +transport I/O. It does not block the SDK receive callback waiting for a synchronous model/tool +call. Shutdown retains and joins Workers before closing clients; an already-running call must +settle first. Model requests use a 30-second timeout without automatic retries. +Cancellation is checked when queued model/tool calls actually start; calls already executing +are still drained cooperatively. +The Worker makes at most five model calls. If the fifth call still requires tools rather than +providing a final answer, it records a sanitized failure, not a successful placeholder result. +Failed/incomplete API responses, structured model refusals, and empty or whitespace-only final +answers also remain unsuccessful. A real final answer returned by an already-running call can +still be saved after cooperative cancellation; refusals and blank answers cannot. + +Completed results are marked `DELIVERED` only after a successful CE `response.done` send. +The Router identifies paraphrased/translated results explicitly through required +`delivered_task_ids` (an empty array for unrelated chat). Invalid or missing IDs fail the turn +before speech rather than silently losing delivery bookkeeping. Nonempty delivery IDs also +require a nonblank spoken message before the turn can claim any results. +Delivery claims are checked against complete result bodies included in that Router request, +not the task store after model I/O. Results that finish during routing remain available through +the direct result/status or automatic Worker-result output paths. Index-only results require +`get_task_result`; their IDs alone cannot authorize a delivery claim, even when history remains. +Direct result/status paths also track the task ID. Text-substring matching is not used. +Retained `COMPLETED` and `DELIVERED` IDs are both valid for repetition or translation; +replaying a delivered result does not reset or repeat its delivery-state transition. +This is transport-level delivery, not proof that all audio was heard. Playback corrections still +update history. Failure messages sent to the caller and Router context are sanitized. + +Task retention is deliberately small: at most 16 tasks per connection, with queries and final +results limited to 8,000 characters each. An oversized query is rejected before task admission; +an oversized Worker result fails instead of being silently truncated or retained. At each Router +turn/task admission, terminal tasks (including undelivered results) older than five minutes +after completion are removed. Capacity eviction removes the oldest terminal outcome, never an +active Worker. Expired/evicted lookups explicitly report that the result is unavailable; `latest` +does not silently select an older task, and eviction never marks a result delivered. +The Router's task-context block is capped at 8,000 characters. Delivered results are included +in full, without an unmarked preview, even after their speech leaves history. If results do not fit, it +receives a compact task index and must use `get_task_result` for a full retained result rather +than treat a clipped answer as complete. These are local limits and existing on-access cleanup, +not a background cleanup service or a promise to retain every result for the entire call. + +State is in memory and is not restored across a disconnected/reconnected socket, host restart, +or replacement container. Use an application-owned durable store if that is required. +The sample does not promise arbitrary cancellation-resistant producer or finalizer support; +its business stream uses cancellable asynchronous calls and ordinary synchronous bookkeeping. + +## Prerequisites + +- Python 3.13 or later and [uv](https://docs.astral.sh/uv/) 0.11.7 or later. +- Azure Developer CLI and Foundry extension versions meeting `azure.yaml`'s `requiredVersions`. +- A Foundry project and model deployment, or permission to provision the declared + `gpt-5.4-mini` deployment. +- A signed-in local Azure CLI identity for model-backed tests, or the Hosted Agent's managed + identity after deployment. The identity needs appropriate Foundry/model-inference access. +- For deployment, matching Hosted Agent ingress and Voice Live support for + `voice_conversation_engine:1.0.0`. A successful local SDK test does not establish that rollout. + +The model deployment, CPU/memory settings, and runtime directory name are retained from the +original sample. The CE manifest uses the distinct Hosted Agent name +`foreground-background-agents-ce` and Voice wrapper name `foreground-background-agents-ce-voice`, +so deploying it in the same project does not replace the original Responses Agent. +The wrapper's `conversationEngine` references the CE Agent. No cloud resource is created by +checking out this sample. + +## Option 1: Azure Developer CLI + +Initialize from a fresh directory, not from a directory already containing `azure.yaml`: + +```bash +mkdir foreground-background-ce +cd foreground-background-ce +azd ai agent init -m https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/azure.yaml +azd auth login +az login +azd provision +azd ai agent run +``` + +The public manifest URL is available after this CE variant is merged. Before then, use the local +manifest's absolute path from a fresh directory. To reuse an existing project/model, configure +that azd environment for those existing resources and do not run `azd provision` or `azd up`. + +After local protocol validation, `azd deploy` deploys the Hosted Agent and its Voice wrapper. +Callers connect to the Voice wrapper's normal Voice WebSocket, not to the internal CE agent leg. + +`azd ai agent invoke` and Agent Inspector do not produce CE messages. Use the dedicated +WebSocket smoke client below. No separate adapter Agent, loopback HTTP request, or OpenAI API key +is required; the model clients authenticate to the configured Foundry project. + +## Option 2: Run locally + +From the sample directory: + +```bash +cd src/foreground-background-agents-voicelive +uv sync --frozen +cp .env.example .env +``` + +Set `FOUNDRY_PROJECT_ENDPOINT` and `AZURE_AI_MODEL_DEPLOYMENT_NAME` in `.env`. +The endpoint is the HTTPS project URL ending in `/api/projects/`. +`APPLICATIONINSIGHTS_CONNECTION_STRING` is optional locally and normally injected when hosted. +The module can be imported without model configuration; configuration is read when starting it. + +```bash +az login +set -a +source .env +set +a +uv run --no-sync python main.py +``` + +The server exposes `GET /readiness` and the CE WebSocket on port 8088 (or `PORT`). +The retained Dockerfile uses Python 3.13 and the same frozen dependency graph as direct deployment. + +In a second terminal, from the runtime directory: + +```bash +uv run --no-sync python scripts/smoke_test.py --text "Hello!" +uv run --no-sync python scripts/smoke_test.py --text "Find the Quincy North fiber specifications." +``` + +Unlike the other CE samples' story/Echo tests, this smoke calls the real Router/model and can +invoke mock Worker tools. It requires valid model access and may take multiple tool rounds. +Expected response order is `session.ready → response.created → response.output_text.delta* +→ response.output_text.done → response.done`, with correlated IDs. No audio is tested here. + +## Tests + +Tests use fake model clients and zero-latency tool doubles; they do not need credentials or +provision Azure resources. They exercise the real SDK CE dispatcher and the real Worker loop. + +```bash +uv sync --frozen +uv run --no-sync python -m pytest -q +uv run --no-sync ruff check . +uv run --no-sync python -m compileall -q -x '[.]venv' . +uv lock --check +``` + +`sample.yaml` declares the same build-readiness commands. The public behavior contract is at +`.azure-pipelines/hosted-agent-tests/python/bring-your-own/conversation_engine/foreground_background/test-spec.yml`. +The shared HTTP cloud runner cannot invoke CE; this path is listed with the other CE samples in +the deployment skiplist. Build-readiness and unit tests are not skipped. Use a dedicated CE/Voice +client for cloud validation. + +No live model or managed-audio deployment validation is implied by these unit tests. +Before rollout, verify platform CE support, provision or reuse the model, grant model access, +deploy the Hosted Agent and Voice wrapper, and test real audio plus interruption. diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/azure.yaml b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/azure.yaml new file mode 100644 index 000000000..70bc04fbb --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/azure.yaml @@ -0,0 +1,83 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +requiredVersions: + azd: '>=1.32.0' + extensions: + azure.ai.agents: '>=1.0.0-beta.13' +name: foreground-background-agents-ce +services: + ai-project: + host: azure.ai.project + deployments: + - name: gpt-5.4-mini + model: + format: OpenAI + name: gpt-5.4-mini + version: '2026-03-17' + sku: + name: GlobalStandard + capacity: 10 + foreground-background-agents-ce: + host: azure.ai.agent + metadata: + tags: + - Foreground + Background Agents + - Voice Optimized + - voice_conversation_engine + project: src/foreground-background-agents-voicelive + language: python + codeConfiguration: + runtime: python_3_13 + entryPoint: main.py + uses: + - ai-project + kind: hosted + name: foreground-background-agents-ce + displayName: Foreground + Background Conversation Engine + description: |- + Voice Live conversation engine with a foreground Router and background Worker. + Uses mock site, Work IQ, document, and repair-procedure tools. + protocols: + - protocol: voice_conversation_engine + version: 1.0.0 + env: + PYTHONNOUSERSITE: "1" + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + container: + resources: + cpu: '0.5' + memory: 1Gi + foreground-background-agents-ce-voice: + host: azure.ai.agent + kind: voice + name: foreground-background-agents-ce-voice + uses: + - ai-project + - foreground-background-agents-ce + conversationEngine: + type: hosted_agent + name: foreground-background-agents-ce + store: false + audio: + input: + format: + type: audio/pcm + rate: 24000 + turnDetection: + type: server_vad + threshold: 0.5 + prefixPaddingMs: 300 + silenceDurationMs: 500 + transcription: + model: azure-speech + output: + format: + type: audio/pcm + rate: 24000 + voice: + type: azure-standard + name: en-US-AvaNeural + outputModalities: + - audio +infra: + provider: microsoft.foundry diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/sample.yaml b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/sample.yaml new file mode 100644 index 000000000..bb1849000 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/sample.yaml @@ -0,0 +1,13 @@ +name: Foreground + Background Conversation Engine +description: A model-backed Router/Worker sample on the Voice Live CE protocol. +build: >- + cd src/foreground-background-agents-voicelive && + uv sync --frozen +validate: >- + cd src/foreground-background-agents-voicelive && + uv run --no-sync python -m compileall -q -x '[.]venv' . && + uv run --no-sync python -c "import main" && + uv run --no-sync ruff check . +test: >- + cd src/foreground-background-agents-voicelive && + uv run --no-sync python -m pytest -q diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/.dockerignore b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/.dockerignore new file mode 100644 index 000000000..b709ec79b --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/.dockerignore @@ -0,0 +1,26 @@ +**/__pycache__/ +**/*.py[cod] +**/*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ + +# IDE settings +.vscode/ +.idea/ + +# Version control +.git/ +.gitignore + +# Docker files +.dockerignore + +# Docs +README.md + +# Local environment (never bake credentials into the image) +.env diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/.env.example b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/.env.example new file mode 100644 index 000000000..86eb2456e --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/.env.example @@ -0,0 +1,10 @@ +# Foundry project endpoint — auto-injected in hosted containers. +# Only set manually if running without `azd ai agent run`. +# FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ + +# Model deployment name — must match a deployment in your Foundry project. +AZURE_AI_MODEL_DEPLOYMENT_NAME= + +# Application Insights — auto-injected in hosted containers. +# Set for local telemetry (optional but recommended). +# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=... diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/.gitignore b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/.gitignore new file mode 100644 index 000000000..ffff2ac2b --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.env diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/Dockerfile b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/Dockerfile new file mode 100644 index 000000000..1212c9d2b --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/Dockerfile @@ -0,0 +1,29 @@ +FROM ghcr.io/astral-sh/uv:0.11.7 AS uv +FROM python:3.13-slim + +ENV PYTHONNOUSERSITE=1 + +COPY --from=uv /uv /uvx /bin/ + +WORKDIR /app/user_agent +COPY pyproject.toml uv.lock uv.toml ./ +RUN uv sync --frozen --no-dev --no-install-project + +COPY . . + +EXPOSE 8088 + +# Precompile Python bytecode at build time so cold starts don't pay for it. +# These base images ship almost none of the standard library precompiled +# (~2% of stdlib .py files, on both Debian-slim and Azure Linux), so without +# this every new container recompiles hundreds of stdlib modules on first +# import. Dependencies are best-effort (`|| true`) so a stray unparsable file +# vendored by a third-party package can't fail the build; application code is +# compiled strictly so a syntax error surfaces at build time rather than at +# container start. +# PYTHONDONTWRITEBYTECODE is cleared for these commands only: it blocks +# bytecode *writes*, not *reads*, so images that set it still benefit. +RUN PYTHONDONTWRITEBYTECODE= .venv/bin/python -m compileall -q $(.venv/bin/python -c "import sysconfig as s; print(s.get_paths()['stdlib'], s.get_paths()['purelib'])") || true; \ + PYTHONDONTWRITEBYTECODE= .venv/bin/python -m compileall -q -x '[.]venv' . + +CMD ["/app/user_agent/.venv/bin/python", "main.py"] diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/agent_logic.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/agent_logic.py new file mode 100644 index 000000000..43e0d0cf8 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/agent_logic.py @@ -0,0 +1,315 @@ +"""The original Router/Worker workflow, independent of CE wire messages.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from collections import deque +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Any + +from router_agent import ( + ROUTER_SYSTEM_PROMPT, + ROUTER_TOOL_CHOICE, + ROUTER_TOOLS, + build_delivered_context, + build_task_context, + execute_router_tool, +) +from task_store import Task, TaskStatus, TaskStore +from worker_agent import run_worker + +logger = logging.getLogger(__name__) +STATUS_INTERVAL = 5.0 +STATUS_TIMEOUT = 1.0 +MAX_TASK_CONTEXT_CHARACTERS = 8_000 +MAX_MODEL_HISTORY_CHARACTERS = 16_000 + + +@dataclass +class HistoryEntry: + role: str + content: str + response_id: str | None = None + + +@dataclass +class Conversation: + """Business state belongs to one physical CE connection, never the app.""" + + tasks: TaskStore = field(default_factory=TaskStore) + history: deque[HistoryEntry] = field(default_factory=lambda: deque(maxlen=20)) + + def cancel_workers(self) -> None: + for task in self.tasks.active_tasks(): + task.cancel_event.set() + + def reconcile(self, response_id: str, heard_text: str, *, allow_append: bool = False) -> None: + for entry in self.history: + if entry.response_id == response_id: + entry.content = heard_text + return + if allow_append and heard_text: + # Only a first in-flight send can be missing without having been evicted. + self.history.append(HistoryEntry("assistant", heard_text, response_id)) + + +@dataclass +class Turn: + response_id: str + delivered_task_ids: set[str] = field(default_factory=set) + + +def task_message(task: Task) -> str: + if task.status is TaskStatus.FAILED: + return "Sorry, the background work failed. Please try again." + if task.status is TaskStatus.CANCELLED: + return "The background work was cancelled." + return task.result or "No result yet." + + +def text_chunks(text: str) -> list[str]: + # Keep the original voice-friendly chunk size. + return [text[index : index + 40] for index in range(0, len(text), 40)] + + +class ForegroundBackgroundAgent: + """Own model clients and retained Workers; protocol responses are separate.""" + + def __init__(self, chat: Any, responses: Any, model: str) -> None: + self.chat = chat + self.responses = responses + self.model = model + self.workers: set[asyncio.Task[None]] = set() + self._clients: list[Any] = [] + self._async_clients: list[Any] = [] + + @classmethod + def from_environment(cls) -> ForegroundBackgroundAgent: + from azure.ai.projects import AIProjectClient + from azure.ai.projects.aio import AIProjectClient as AsyncAIProjectClient + from azure.identity import DefaultAzureCredential + from azure.identity.aio import DefaultAzureCredential as AsyncDefaultAzureCredential + + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + credential = DefaultAzureCredential() + project = AIProjectClient(endpoint=endpoint, credential=credential) + worker_client = project.get_openai_client(timeout=30.0, max_retries=0) + async_credential = AsyncDefaultAzureCredential() + async_project = AsyncAIProjectClient(endpoint=endpoint, credential=async_credential) + router_client = async_project.get_openai_client(timeout=30.0, max_retries=0) + agent = cls(router_client.chat.completions, worker_client.responses, model) + agent._clients = [worker_client, project, credential] + agent._async_clients = [router_client, async_project, async_credential] + return agent + + async def close(self) -> None: + # Do not cancel coroutine wrappers around running executor calls. + if self.workers: + await asyncio.gather(*tuple(self.workers), return_exceptions=True) + for client in self._async_clients: + await client.close() + for client in self._clients: + await asyncio.to_thread(client.close) + + def start_worker(self, task: Task, history: list[dict[str, Any]]) -> None: + worker = asyncio.create_task(run_worker(task, history, self.responses, self.model)) + self.workers.add(worker) + worker.add_done_callback(self.workers.discard) + + async def respond( + self, + conversation: Conversation, + turn: Turn, + user_input: str, + ) -> AsyncIterator[str]: + # Evaluated only after the CE generation gate opens. Queued inputs do not + # retain old history snapshots that can miss later heard_text corrections. + # Share one bounded, chronological snapshot with Router and new Workers. + # Drop whole older entries, never silently clip technical instructions. + history = [] + remaining = MAX_MODEL_HISTORY_CHARACTERS + for entry in reversed(conversation.history): + if not entry.content: + continue + if len(entry.content) > remaining: + break + history.append({"type": "message", "role": entry.role, "content": entry.content}) + remaining -= len(entry.content) + history.reverse() + conversation.history.append(HistoryEntry("user", user_input)) + input_items = [*history, {"type": "message", "role": "user", "content": user_input}] + store = conversation.tasks + store.cleanup() + completed = store.completed_tasks() + # Keep references across Router I/O, including tasks that fail or get cancelled. + active = store.active_tasks() + context_parts = [ + build_task_context(store), + "\n".join( + f'Completed task {task.task_id} "{task.query}": {task.result}' + for task in reversed(completed) + ), + "\n".join( + f"Task {task.task_id}: {task_message(task)}" for task in store.terminal_tasks() + ), + build_delivered_context(store), + ] + context = "\n\n".join(part for part in context_parts if part) + # Only complete result bodies in this request authorize Router delivery claims. + # Do not let Worker completion during Router I/O grant that authority retroactively. + visible_result_ids = { + task.task_id for task in completed + store.delivered_tasks() + } + if len(context) > MAX_TASK_CONTEXT_CHARACTERS: + # Do not present a clipped result as a complete, deliverable answer. + visible_result_ids.clear() + tasks = ( + active + list(reversed(completed)) + + store.terminal_tasks() + store.delivered_tasks() + ) + context = "\n".join( + f'{task.status.value.upper()} task {task.task_id}: "{task.query[:200]}"' + for task in tasks + ) + notice = ( + "\n[Result bodies omitted to fit task context. Use get_task_result for full " + "retained results; do not guess or deliver partial results from this index.]" + ) + context = context[:MAX_TASK_CONTEXT_CHARACTERS - len(notice)] + notice + messages = [{"role": "system", "content": ROUTER_SYSTEM_PROMPT}] + messages.extend({"role": item["role"], "content": item["content"]} for item in input_items) + if context: + messages.insert(-1, {"role": "system", "content": context}) + response = await self.chat.create( + model=self.model, + messages=messages, + tools=[ + { + "type": "function", + "function": {key: value for key, value in tool.items() if key != "type"}, + } + for tool in ROUTER_TOOLS + ], + tool_choice=ROUTER_TOOL_CHOICE, + parallel_tool_calls=False, + ) + if not response.choices: + raise ValueError("Router returned no choice") + calls = response.choices[0].message.tool_calls or [] + if len(calls) != 1: + raise ValueError("Router must return exactly one tool call") + call = calls[0].function + arguments = json.loads(call.arguments) + if call.name == "respond_directly": + message = arguments.get("message", "") + reported = arguments.get("delivered_task_ids") + if ( + not isinstance(reported, list) + or any(not isinstance(task_id, str) for task_id in reported) + or not set(reported).issubset(visible_result_ids) + ): + raise ValueError("Router returned invalid delivered task IDs") + if not isinstance(message, str) or (reported and not message.strip()): + raise ValueError("Router delivery claims require a nonblank spoken message") + turn.delivered_task_ids.update(reported) + active = [task for task in active if task.task_id not in turn.delivered_task_ids] + else: + result, new_task = execute_router_tool(call.name, arguments, store) + data = json.loads(result) + task = store.get(arguments.get("task_id", "latest")) + if call.name == "start_task": + if new_task is not None: + self.start_worker( + new_task, + [*history, {"type": "message", "role": "user", "content": new_task.query}], + ) + active.append(new_task) + message = arguments.get("ack_message", "On it.") + else: + message = "I'm busy; I haven't started another task." + elif call.name == "cancel_task": + message = ( + (arguments.get("ack_message") or "Cancellation requested.") + if data["cancelled"] + else "There is no active work to cancel." + ) + elif task is None: + message = data.get("error", "No task found") + elif task and task.status in ( + TaskStatus.COMPLETED, + TaskStatus.DELIVERED, + TaskStatus.FAILED, + TaskStatus.CANCELLED, + ): + message = task_message(task) + # This lookup already conveys the terminal outcome; do not repeat it below. + active = [candidate for candidate in active if candidate is not task] + if task.status is TaskStatus.COMPLETED: + turn.delivered_task_ids.add(task.task_id) + else: + message = arguments.get("ack_message") or data.get("error", "Still working on it.") + for chunk in text_chunks(message): + yield chunk + has_output = bool(message) + + next_status = asyncio.get_running_loop().time() + STATUS_INTERVAL + status_count = 0 + while any(task.status in (TaskStatus.QUEUED, TaskStatus.RUNNING) for task in active): + await asyncio.sleep(0.1) + if asyncio.get_running_loop().time() < next_status or status_count >= 5: + continue + next_status = asyncio.get_running_loop().time() + STATUS_INTERVAL + status_count += 1 + try: + update = await asyncio.wait_for( + self.chat.create( + model=self.model, + messages=[ + { + "role": "system", + "content": ( + "Give a voice-friendly progress update of at most six words, " + "in the user's language. Do not say task or tool." + ), + }, + { + "role": "user", + "content": ( + f"User message (language reference): {user_input}\n" + + "; ".join( + f"{task.query}: round {task.rounds_completed + 1}, " + f"current tool {task.current_tool or 'thinking'}" + for task in active + if task.status in (TaskStatus.QUEUED, TaskStatus.RUNNING) + ) + ), + }, + ], + ), + timeout=STATUS_TIMEOUT, + ) + # Optional status output must not precede a newly ready result. + if not any(t.status in (TaskStatus.QUEUED, TaskStatus.RUNNING) for t in active): + break + status = update.choices[0].message.content or "" + if status and has_output: + yield "\n\n" + for chunk in text_chunks(status): + yield chunk + has_output = has_output or bool(status) + except Exception as error: + logger.warning("Status update skipped; error_type=%s", type(error).__name__) + for task in active: + if task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED): + if task.status is TaskStatus.COMPLETED: + turn.delivered_task_ids.add(task.task_id) + if has_output: + yield "\n\n" + for chunk in text_chunks(task_message(task)): + yield chunk + has_output = True diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/azdignore.txt b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/azdignore.txt new file mode 100644 index 000000000..4a74eabf4 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/azdignore.txt @@ -0,0 +1,3 @@ +agent.manifest.yaml +agent.yaml +.env.example diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/conversation_runtime.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/conversation_runtime.py new file mode 100644 index 000000000..d4cd71642 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/conversation_runtime.py @@ -0,0 +1,216 @@ +"""Bind the Router/Worker workflow to the existing native CE runtime.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any + +from azure.ai.agentserver.invocations.voice import ( + InputTextPart, + ResponseCancelled, + ResponseDone, + ResponseOutputTextDelta, + ResponseOutputTextDone, + Session, + SessionEnd, + SessionStart, + UserBargeIn, + UserMessage, + UserNoInput, + VoiceAgentServerHost, + new_response_id, +) + +from agent_logic import Conversation, ForegroundBackgroundAgent, HistoryEntry, Turn +from state import MAX_MESSAGE_CHARACTERS, InputOrigin, OperationOutcome, SessionState +from task_store import TaskStatus +from voice_runtime import IDLE_REMINDER_TEXT, VoiceRuntime + + +class HistorySession: + """Record only successfully sent output, not merely generated text.""" + + def __init__( + self, + session: Session, + state: SessionState, + conversation: Conversation, + turn: Turn, + ) -> None: + self.session = session + self.state = state + self.conversation = conversation + self.turn = turn + self._sent_text = False + self.first_text_pending = False + + async def send(self, message: Any) -> None: + is_delta = isinstance(message, ResponseOutputTextDelta) + record_text = is_delta or ( + isinstance(message, ResponseOutputTextDone) and not self._sent_text + ) + self.first_text_pending = record_text and not self._sent_text + try: + await self.session.send(message) + finally: + self.first_text_pending = False + if record_text: + self._sent_text = True + operation = self.state.responses.get(self.turn.response_id) + if not self.state.active or (operation and operation.cancellation_requested): + return + if record_text: + entry = next( + ( + entry + for entry in self.conversation.history + if entry.response_id == self.turn.response_id + ), + None, + ) + if entry is None: + entry = HistoryEntry("assistant", "", self.turn.response_id) + self.conversation.history.append(entry) + entry.content += message.delta if is_delta else message.text + elif isinstance(message, ResponseDone): + for task_id in self.turn.delivered_task_ids: + task = self.conversation.tasks.get(task_id) + if task and task.status is TaskStatus.COMPLETED: + task.status = TaskStatus.DELIVERED + + +class ConversationRuntime(VoiceRuntime): + def __init__(self, agent: ForegroundBackgroundAgent) -> None: + super().__init__() + self.agent = agent + self.conversations: dict[Session, Conversation] = {} + self._shutdown: asyncio.Task[None] | None = None + + async def on_session_start(self, session: Session, event: SessionStart) -> None: + already_active = self._state_for(session) is not None + await super().on_session_start(session, event) + if not already_active and self._state_for(session) is not None and event.greeting: + conversation = self.conversations.setdefault(session, Conversation()) + conversation.history.append(HistoryEntry("assistant", event.greeting)) + + async def on_user_message(self, session: Session, event: UserMessage) -> None: + received_at = time.monotonic() + state = self._state_for(session) + if state is None: + return + if not await self._claim_input(session, state, event.item_id, InputOrigin.USER): + if not state.active: + self._detach(session) + return + text = "".join(part.text for part in event.content if isinstance(part, InputTextPart)) + if any(not isinstance(part, InputTextPart) for part in event.content): + reason = "unsupported_input" + elif not text.strip() or len(text) > MAX_MESSAGE_CHARACTERS: + reason = "empty_input" if not text.strip() else "input_too_large" + elif not state.can_start_response(): + reason = "capacity_exceeded" + else: + conversation = self.conversations.setdefault(session, Conversation()) + turn = Turn(new_response_id()) + self._coordinator.start_response( + HistorySession(session, state, conversation, turn), + state, + event.item_id, + self.agent.respond(conversation, turn, text), + received_at, + response_id=turn.response_id, + ) + return + self._coordinator.schedule_none( + session, + state, + event.item_id, + InputOrigin.USER, + reason=reason, + ) + if not state.active: + self._detach(session) + + def _reconcile(self, session: Session, response_id: str, heard_text: str) -> None: + state = self._state_for(session) + conversation = self.conversations.get(session) + if state and conversation and response_id in state.heard_text_by_response: + state.record_heard_text(response_id, heard_text) + operation = state.responses.get(response_id) + pending_first_send = ( + operation is not None + and isinstance(operation.session, HistorySession) + and operation.session.first_text_pending + ) + conversation.reconcile( + response_id, state.heard_text_by_response[response_id], + allow_append=pending_first_send, + ) + + async def on_user_barge_in(self, session: Session, event: UserBargeIn) -> None: + self._reconcile(session, event.response_id, event.heard_text) + await super().on_user_barge_in(session, event) + + async def on_response_cancelled(self, session: Session, event: ResponseCancelled) -> None: + self._reconcile(session, event.response_id, event.heard_text) + await super().on_response_cancelled(session, event) + + def _detach(self, session: Session) -> None: + conversation = self.conversations.pop(session, None) + if conversation is not None: + conversation.cancel_workers() + + async def on_user_no_input(self, session: Session, event: UserNoInput) -> None: + state = self._state_for(session) + if state is None: + return + if not await self._claim_input(session, state, event.item_id, InputOrigin.NO_INPUT): + if not state.active: + self._detach(session) + return + if event.count >= 3: + self._detach(session) + self._coordinator.detach_terminating_state(state, OperationOutcome.END_CALL) + self._coordinator.schedule_end_call(session, state, InputOrigin.NO_INPUT) + return + if not state.can_start_response(): + self._coordinator.schedule_none( + session, state, event.item_id, InputOrigin.NO_INPUT, reason="capacity_exceeded", + ) + if not state.active: + self._detach(session) + return + conversation = self.conversations.setdefault(session, Conversation()) + turn = Turn(new_response_id()) + self._coordinator.start_response( + HistorySession(session, state, conversation, turn), state, event.item_id, + text=IDLE_REMINDER_TEXT, streaming=False, response_id=turn.response_id, + ) + + async def on_session_end(self, session: Session, event: SessionEnd) -> None: + self._detach(session) + await super().on_session_end(session, event) + + def on_connection_terminating(self, session: Session) -> None: + self._detach(session) + super().on_connection_terminating(session) + + async def close(self) -> None: + if self._shutdown is None: + self._shutdown = asyncio.create_task(self._close()) + await asyncio.shield(self._shutdown) + + async def _close(self) -> None: + for session in tuple(self.conversations): + self._detach(session) + await super().close() + await self.agent.close() + + +def create_app(agent: ForegroundBackgroundAgent, **options: Any) -> VoiceAgentServerHost: + runtime = ConversationRuntime(agent) + app = VoiceAgentServerHost(**options) + runtime.bind(app) + app.state.voice_runtime = runtime + return app diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/main.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/main.py new file mode 100644 index 000000000..8e5e98573 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/main.py @@ -0,0 +1,16 @@ +"""Foreground/background agents hosted on the conversation-engine protocol.""" + +import logging +import os + +from agent_logic import ForegroundBackgroundAgent +from conversation_runtime import create_app + + +def main() -> None: + logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper()) + create_app(ForegroundBackgroundAgent.from_environment()).run() + + +if __name__ == "__main__": + main() diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/pyproject.toml b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/pyproject.toml new file mode 100644 index 000000000..a2f1194e8 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "foreground-background-agents-voicelive" +version = "0.1.0" +description = "A foreground/background conversation engine for Voice Live." +requires-python = ">=3.13" +dependencies = [ + "azure-ai-agentserver-invocations==1.3.0b1", + "azure-ai-agentserver-core==2.3.0b2", + "azure-ai-projects==2.0.1", + "azure-identity==1.25.3", + "opentelemetry-api==1.45.1", + # Keep the original Foundry Projects/OpenAI 2 model-client compatibility. + "httpx==0.28.1", + "openai==2.54.0", +] + +[dependency-groups] +dev = [ + "httpx2==2.12.0", + "pytest==9.1.1", + "pytest-asyncio==1.4.0", + "ruff==0.16.5", + "websockets==17.1", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "ASYNC"] diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/response_coordinator.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/response_coordinator.py new file mode 100644 index 000000000..5ccfd976b --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/response_coordinator.py @@ -0,0 +1,747 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Response execution and terminal sequencing for Voice Live Bridge sessions.""" + +from __future__ import annotations + +import asyncio +import contextvars +import json +import logging +import sys +from collections.abc import AsyncIterable, Coroutine, Mapping +from contextlib import nullcontext +from typing import Any + +from azure.ai.agentserver.invocations.voice import ( + AgentError, + EndCall, + EndCallMode, + ResponseCancel, + ResponseCreated, + ResponseDone, + ResponseNone, + ResponseOutputDataDone, + ResponseOutputTextDelta, + ResponseOutputTextDone, + Session, + new_item_id, + new_response_id, +) + +from response_telemetry import ResponseTelemetry +from state import ( + MAX_OUTPUT_BYTES, + MAX_PENDING_PROACTIVE, + MAX_TERMINAL_SENDS, + ApplicationTurn, + InputOrigin, + OperationOutcome, + ProactiveRequest, + ResponseOperation, + SessionState, +) + +logger = logging.getLogger("voice_live_bridge") +MAX_OUTPUT_CHUNKS = 4096 + +OutputChunk = str | Mapping[str, Any] +MAX_DATA_BYTES = 65_536 + +class ResponseCoordinator: + """Coordinate response tasks for explicit connection-scoped state.""" + + def __init__(self) -> None: + self._detached_tasks: set[asyncio.Task[None]] = set() + + def send_echo( + self, + session: Session, + state: SessionState, + input_item_id: str, + chunks: AsyncIterable[OutputChunk], + received_at: float, + ) -> bool: + """Schedule a cancellable echo without blocking incoming control events.""" + return self.start_response(session, state, input_item_id, chunks, received_at) + + def start_response( + self, + session: Session, + state: SessionState, + input_item_id: str | None, + text: OutputChunk | AsyncIterable[OutputChunk], + user_message_received_at: float | None = None, + *, + streaming: bool = True, + voice: dict[str, Any] | None = None, + response_id: str | None = None, + response_started: bool = False, + ) -> bool: + """Schedule output while the host continues to receive interruption events.""" + if not state.can_start_response(): + return False + response_id = response_id or new_response_id() + input_ids = (input_item_id,) if input_item_id is not None else () + initialization_error: BaseException | None = None + # Retain the accepted source as the close target if iterator setup fails. + iterator = self._fixed_chunks(text) if isinstance(text, (str, Mapping)) else text + try: + iterator = aiter(iterator) + except (Exception, asyncio.CancelledError) as error: + # Keep source setup failures inside the ordered, tracked response lifecycle. + initialization_error = error + operation = ResponseOperation( + session=session, + state=state, + response_id=response_id, + input_ids=input_ids, + turn=ApplicationTurn(), + response_started=response_started, + output_iterator=iterator, + telemetry=( + ResponseTelemetry(response_id, input_item_id, user_message_received_at) + if input_item_id is not None and user_message_received_at is not None + else None + ), + ) + state.responses[response_id] = operation + state.track_response(response_id) + if input_item_id is not None: + state.input_responses[input_item_id] = response_id + self._start_task( + state, + operation, + self._run_response(operation, text, streaming, voice, initialization_error), + ) + return True + + def start_self_cancel( + self, + session: Session, + state: SessionState, + input_item_id: str, + text: str, + user_message_received_at: float, + ) -> bool: + """Start a response that requests its own cancellation.""" + if not state.can_start_response(): + return False + response_id = new_response_id() + operation = ResponseOperation( + session=session, + state=state, + response_id=response_id, + input_ids=(input_item_id,), + turn=ApplicationTurn(), + response_started=False, + terminal_event=asyncio.Event(), + telemetry=ResponseTelemetry( + response_id, + input_item_id, + user_message_received_at, + ), + ) + state.responses[operation.response_id] = operation + state.track_response(operation.response_id) + state.input_responses[input_item_id] = operation.response_id + self._start_task(state, operation, self._run_self_cancel(operation, text)) + return True + + def reserve_proactive(self, state: SessionState, text: str) -> str | None: + """Reserve bounded proactive admission state.""" + if not state.active or len(state.proactive_requests) >= MAX_PENDING_PROACTIVE: + return None + response_id = new_response_id() + state.proactive_requests[response_id] = ProactiveRequest(text) + return response_id + + async def send_proactive_request( + self, session: Session, state: SessionState, response_id: str + ) -> bool: + """Request proactive admission and roll back the reservation on failure.""" + if not state.active or response_id not in state.proactive_requests: + state.proactive_requests.pop(response_id, None) + return False + try: + await session.send( + ResponseCreated( + response_id=response_id, + admission_timeout_ms=5000, + supersede_key="voice-live-bridge-basic-python", + ) + ) + except BaseException: + state.proactive_requests.pop(response_id, None) + raise + return True + + async def accept_proactive( + self, session: Session, state: SessionState, response_id: str + ) -> None: + """Start reserved proactive work after Voice Live accepts it.""" + request = state.proactive_requests.pop(response_id, None) + if request is None or not state.active: + return + if not state.can_start_response(): + self._cancel_accepted_proactive(session, state, response_id) + return + started = self.start_response( + session, + state, + input_item_id=None, + text=request.text, + streaming=False, + voice=None, + response_id=response_id, + response_started=True, + ) + if not started: + self._cancel_accepted_proactive(session, state, response_id) + + def _cancel_accepted_proactive( + self, session: Session, state: SessionState, response_id: str, + ) -> None: + # An accepted admission already owns a wire generation. Keep its cancellation + # request off receive dispatch, and wait for the winning protocol outcome. + if state.terminal_send_count >= MAX_TERMINAL_SENDS: + self.detach_terminating_state(state, OperationOutcome.END_CALL) + self.schedule_end_call( + session, state, InputOrigin.PROACTIVE, reason="capacity_exceeded", + ) + return + operation = ResponseOperation( + session, state, response_id, (), ApplicationTurn(), True, + terminal_event=asyncio.Event(), capacity_released=True, + ) + state.responses[response_id] = operation + state.track_response(response_id) + + async def cancel() -> None: + await state.generation_gate.acquire() + operation.generation_acquired = True + await self._send(operation, ResponseCancel( + response_id=response_id, reason="capacity_exceeded", + )) + assert operation.terminal_event is not None + await operation.terminal_event.wait() + + self._start_task(state, operation, cancel()) + + def barge_in(self, state: SessionState, response_id: str, heard_text: str) -> None: + """Record what the caller heard and cancel any remaining output.""" + state.record_heard_text(response_id, heard_text) + self.cancel_response(state, response_id, OperationOutcome.CANCELLED) + + def response_cancelled(self, state: SessionState, response_id: str, heard_text: str) -> None: + """Synchronize playback state even when generation has already finished.""" + state.record_heard_text(response_id, heard_text) + self.cancel_response(state, response_id, OperationOutcome.CANCELLED) + + def response_timeout( + self, + state: SessionState, + response_id: str | None, + item_ids: tuple[str, ...] | list[str] | None, + ) -> None: + """Cancel proactive or active work selected by a timeout event.""" + if response_id is not None: + state.proactive_requests.pop(response_id, None) + self.cancel_response(state, response_id, OperationOutcome.TIMEOUT) + return + for input_id in item_ids or (): + if mapped_response_id := state.input_responses.get(input_id): + self.cancel_response(state, mapped_response_id, OperationOutcome.TIMEOUT) + + @staticmethod + def cancel_response(state: SessionState, response_id: str, outcome: OperationOutcome) -> None: + operation = state.responses.get(response_id) + if operation is not None: + operation.request_stop(outcome) + + async def send_none( + self, + session: Session, + input_item_id: str, + origin: InputOrigin, + *, + reason: str = "no_reply_needed", + ) -> None: + """Send a terminal response.none decision.""" + del origin + turn = ApplicationTurn() + try: + with turn.activate(): + await session.send(ResponseNone(in_reply_to=(input_item_id,), reason=reason)) + turn.complete(outcome=OperationOutcome.NONE, output_item_count=0) + except BaseException: + if not turn.is_completed: + turn.complete(outcome=OperationOutcome.ERROR, output_item_count=0) + raise + + async def send_error(self, session: Session, input_item_id: str, origin: InputOrigin) -> None: + """Send the sample response-scoped error sequence.""" + del origin + response_id = new_response_id() + response_started = False + turn = ApplicationTurn() + try: + with turn.activate(): + await session.send( + ResponseCreated(response_id=response_id, in_reply_to=(input_item_id,)) + ) + response_started = True + await session.send( + AgentError( + code="sample_error", + message="The sample emitted the requested error.", + response_id=response_id, + ) + ) + turn.complete( + outcome=OperationOutcome.ERROR, + response_id=response_id, + output_item_count=0, + ) + except BaseException: + if not turn.is_completed: + turn.complete( + outcome=OperationOutcome.ERROR, + response_id=response_id if response_started else None, + output_item_count=0, + ) + raise + + async def send_session_error(self, session: Session, origin: InputOrigin) -> None: + """Send the sample session-scoped error.""" + del origin + turn = ApplicationTurn() + try: + with turn.activate(): + await session.send( + AgentError( + code="sample_session_error", + message="The sample emitted the requested session error.", + ) + ) + turn.complete(outcome=OperationOutcome.ERROR, output_item_count=0) + except BaseException: + if not turn.is_completed: + turn.complete(outcome=OperationOutcome.ERROR, output_item_count=0) + raise + + async def send_end_call( + self, + session: Session, + mode: EndCallMode, + origin: InputOrigin, + *, + reason: str | None = None, + ) -> None: + """Send a terminal call instruction.""" + turn = ApplicationTurn() + try: + with turn.activate(): + await session.send( + EndCall( + reason=reason + or ( + "repeated_no_input" + if origin is InputOrigin.NO_INPUT + else "sample_completed" + ), + mode=mode, + ) + ) + turn.complete(outcome=OperationOutcome.END_CALL, output_item_count=0) + except BaseException: + if not turn.is_completed: + turn.complete(outcome=OperationOutcome.ERROR, output_item_count=0) + raise + + async def terminate_state(self, state: SessionState, outcome: OperationOutcome) -> None: + """Retain the workers before joining; cancelling a join does not cancel cleanup.""" + self.detach_terminating_state(state, outcome) + tasks = [operation.task for operation in tuple(state.responses.values()) + if operation.task is not None] + if tasks: + await asyncio.gather(*(asyncio.shield(task) for task in tasks), return_exceptions=True) + + def detach_terminating_state(self, state: SessionState, outcome: OperationOutcome) -> None: + state.terminating = True + state.proactive_requests.clear() + for operation in tuple(state.responses.values()): + operation.request_stop(outcome) + if operation.task is not None: + self._retain_task(operation.task) + self._clear_state(state) + + def _retain_task(self, task: asyncio.Task[None]) -> None: + self._detached_tasks.add(task) + task.add_done_callback(self._detached_tasks.discard) + + async def wait_for_detached_tasks(self) -> None: + while self._detached_tasks: + await asyncio.gather( + *(asyncio.shield(task) for task in tuple(self._detached_tasks)), + return_exceptions=True, + ) + # gather() can finish synchronously; let ownership-release callbacks run. + await asyncio.sleep(0) + + def schedule_none( + self, session: Session, state: SessionState, input_item_id: str, + origin: InputOrigin, *, reason: str, + ) -> None: + if not state.active: + return + if state.terminal_send_count >= MAX_TERMINAL_SENDS: + self.detach_terminating_state(state, OperationOutcome.END_CALL) + self.schedule_end_call(session, state, origin, reason="capacity_exceeded") + return + operation = ResponseOperation( + session, state, new_response_id(), (input_item_id,), ApplicationTurn(), False, + capacity_released=True, + ) + state.responses[operation.response_id] = operation + state.input_responses[input_item_id] = operation.response_id + + async def decline() -> None: + await self._await_resolution(operation) + self._ensure_writable(operation) + await self.send_none(session, input_item_id, origin, reason=reason) + if not operation.cancellation_requested: + operation.terminal_sent = True + operation.select_outcome(OperationOutcome.NONE) + + self._start_task(state, operation, decline()) + + def schedule_end_call( + self, session: Session, state: SessionState, origin: InputOrigin, + *, reason: str | None = None, + ) -> None: + operation = ResponseOperation( + session, state, new_response_id(), (), ApplicationTurn(), False, + capacity_released=True, + ) + state.responses[operation.response_id] = operation + + async def end_call() -> None: + await self.send_end_call(session, EndCallMode.DRAIN, origin, reason=reason) + operation.terminal_sent = True + operation.select_outcome(OperationOutcome.END_CALL) + + self._start_task(state, operation, end_call(), requires_active=False) + assert operation.task is not None + self._retain_task(operation.task) + + @staticmethod + async def _await_resolution(operation: ResponseOperation) -> None: + if operation.previous_resolution is not None: + await asyncio.shield(operation.previous_resolution) + + @staticmethod + async def _finish_resolution(operation: ResponseOperation) -> None: + if operation.resolution_done is not None and not operation.resolution_done.done(): + await ResponseCoordinator._await_resolution(operation) + operation.resolution_done.set_result(None) + + @staticmethod + def _ensure_writable(operation: ResponseOperation) -> None: + if ( + operation.cancellation_requested or operation.terminal_sent + or not operation.state.active + ): + raise asyncio.CancelledError() + + async def _send(self, operation: ResponseOperation, message: Any) -> None: + self._ensure_writable(operation) + await operation.session.send(message) + + @staticmethod + async def _close_output(operation: ResponseOperation) -> None: + iterator, operation.output_iterator = operation.output_iterator, None + if iterator is None or (closer := getattr(iterator, "aclose", None)) is None: + return + primary_error = sys.exception() + operation.closing = True + try: + await closer() + except (Exception, asyncio.CancelledError) as error: + if primary_error is None or isinstance(primary_error, GeneratorExit): + raise + logger.warning("Producer close failed; error_type=%s", type(error).__name__) + finally: + operation.closing = False + + def _start_task( + self, state: SessionState, operation: ResponseOperation, + coroutine: Coroutine[Any, Any, None], *, requires_active: bool = True, + ) -> None: + if operation.input_ids: + operation.previous_resolution = state.input_resolution_tail + operation.resolution_done = asyncio.get_running_loop().create_future() + state.input_resolution_tail = operation.resolution_done + + async def run() -> None: + operation.running = True + try: + if requires_active: + self._ensure_writable(operation) + elif operation.cancellation_requested: + raise asyncio.CancelledError() + await coroutine + finally: + operation.finalizing = True + coroutine.close() + try: + # Ordering records the input decision, not completion of resource cleanup. + await self._finish_resolution(operation) + finally: + try: + await self._close_output(operation) + finally: + operation.release_generation() + + wrapper = run() + try: + activation = operation.telemetry.activate() if operation.telemetry else nullcontext() + with activation: + task = asyncio.create_task(wrapper, name=f"voice-response-{operation.response_id}") + except BaseException: + wrapper.close() + coroutine.close() + self._finalize(state, operation, OperationOutcome.ABANDONED) + raise + operation.task = task + task.add_done_callback( + lambda done: self._task_done(state, operation, done), context=contextvars.Context(), + ) + + async def _run_response( + self, + operation: ResponseOperation, + text: OutputChunk | AsyncIterable[OutputChunk], + streaming: bool, + voice: dict[str, Any] | None, + initialization_error: BaseException | None = None, + ) -> None: + try: + await self._await_resolution(operation) + await operation.state.generation_gate.acquire() + operation.generation_acquired = True + self._ensure_writable(operation) + if not operation.response_started: + await self._send(operation, + ResponseCreated( + response_id=operation.response_id, + in_reply_to=operation.input_ids, + ) + ) + operation.response_started = True + await self._finish_resolution(operation) + if initialization_error is not None: + raise initialization_error + with operation.turn.activate(): + await self._send_output(operation, text, streaming, voice) + operation.select_outcome(OperationOutcome.RESPONSE) + except asyncio.CancelledError as error: + if operation.cancellation_requested or not operation.state.active: + raise + if operation.telemetry is not None: + operation.telemetry.record_failure(error) + await self._report_failure(operation) + except Exception as exception: + if operation.telemetry is not None: + operation.telemetry.record_failure(exception) + logger.warning("Voice response failed; error_type=%s", type(exception).__name__) + await self._report_failure(operation) + + async def _send_output( + self, + operation: ResponseOperation, + text: OutputChunk | AsyncIterable[OutputChunk], + streaming: bool, + voice: dict[str, Any] | None, + ) -> None: + item_id: str | None = None + del text # The accepted iterator is owned before the task starts. + completed_text = "" + output_bytes = 0 + output_chunks = 0 + data_bytes = 0 + + async def finish_text() -> None: + nonlocal item_id, completed_text + if item_id is not None: + await self._send(operation, + ResponseOutputTextDone( + response_id=operation.response_id, + item_id=item_id, + text=completed_text, + voice=voice, + ) + ) + operation.output_item_count += 1 + item_id, completed_text = None, "" + + iterator = operation.output_iterator + assert iterator is not None + try: + async for chunk in iterator: + self._ensure_writable(operation) + output_chunks += 1 + if output_chunks > MAX_OUTPUT_CHUNKS: + raise ValueError("Response exceeds its chunk budget") + if isinstance(chunk, Mapping): + # SDK input objects are deeply frozen; preserve nested JSON values. + data_bytes += len( + json.dumps(chunk, default=dict, ensure_ascii=False, allow_nan=False, + separators=(",", ":")) + .encode("utf-8") + ) + if data_bytes > MAX_DATA_BYTES: + raise ValueError("Application data exceeds its byte budget") + # A data item cannot interrupt an unfinished text item. + await finish_text() + await self._send(operation, + ResponseOutputDataDone( + response_id=operation.response_id, + item_id=new_item_id(), + data=chunk, + ) + ) + operation.output_item_count += 1 + await asyncio.sleep(0) + continue + if not isinstance(chunk, str): + raise ValueError("Expected a text chunk or JSON object") + if not chunk: + await asyncio.sleep(0) + continue + output_bytes += len(chunk.encode("utf-8")) + if output_bytes > MAX_OUTPUT_BYTES: + raise ValueError("Response text exceeds its byte budget") + if item_id is None: + item_id = new_item_id() + completed_text += chunk + if streaming: + await self._send(operation, + ResponseOutputTextDelta( + response_id=operation.response_id, + item_id=item_id, + delta=chunk, + voice=voice, + ) + ) + # Let control callbacks run even if the producer/send completes synchronously. + await asyncio.sleep(0) + finally: + # Release producer resources on cancellation or a failed send, not only on exhaustion. + await self._close_output(operation) + await finish_text() + if operation.output_item_count == 0: + raise ValueError("Response ended without output") + await self._send(operation,ResponseDone(response_id=operation.response_id)) + operation.terminal_sent = True + operation.release_capacity() + if operation.telemetry is not None: + operation.telemetry.record_response_done_sent() + + @staticmethod + async def _fixed_chunks(text: OutputChunk) -> AsyncIterable[OutputChunk]: + yield text + + async def _run_self_cancel(self, operation: ResponseOperation, text: str) -> None: + try: + await self._await_resolution(operation) + await operation.state.generation_gate.acquire() + operation.generation_acquired = True + self._ensure_writable(operation) + item_id = new_item_id() + with operation.turn.activate(): + await self._send(operation, + ResponseCreated( + response_id=operation.response_id, + in_reply_to=operation.input_ids, + ) + ) + operation.response_started = True + await self._finish_resolution(operation) + if len(text.encode("utf-8")) > MAX_OUTPUT_BYTES: + raise ValueError("Response text exceeds its output budget") + await self._send(operation, + ResponseOutputTextDelta( + response_id=operation.response_id, + item_id=item_id, + delta=text, + ) + ) + await self._send(operation, + ResponseCancel( + response_id=operation.response_id, + reason="sample_self_correction", + ) + ) + assert operation.terminal_event is not None + await operation.terminal_event.wait() + except asyncio.CancelledError as error: + if operation.cancellation_requested or not operation.state.active: + raise + if operation.telemetry is not None: + operation.telemetry.record_failure(error) + await self._report_failure(operation) + except Exception as error: + logger.warning("Self-cancelling response failed; error_type=%s", type(error).__name__) + await self._report_failure(operation) + + def _task_done( + self, + state: SessionState, + operation: ResponseOperation, + task: asyncio.Task[None], + ) -> None: + if not task.cancelled() and (error := task.exception()) is not None: + logger.warning("Response task failed; error_type=%s", type(error).__name__) + self._finalize(state, operation, OperationOutcome.ABANDONED) + + @staticmethod + def _finalize( + state: SessionState, + operation: ResponseOperation, + fallback: OperationOutcome, + ) -> None: + try: + operation.complete(fallback) + except Exception as error: + logger.warning("Response telemetry failed; error_type=%s", type(error).__name__) + finally: + state.responses.pop(operation.response_id, None) + for input_id in operation.input_ids: + if state.input_responses.get(input_id) == operation.response_id: + state.input_responses.pop(input_id, None) + + @staticmethod + def _clear_state(state: SessionState) -> None: + state.heard_text_by_response.clear() + state.seen_inputs.clear() + + async def _report_failure(self, operation: ResponseOperation) -> None: + if ( + operation.cancellation_requested or operation.terminal_sent + or not operation.state.active + ): + return + if not operation.response_started: + operation.select_outcome(OperationOutcome.ERROR) + return + try: + await self._send(operation, AgentError( + code="generation_failed", + message="The response could not be generated.", + response_id=operation.response_id, + )) + operation.terminal_sent = True + operation.select_outcome(OperationOutcome.ERROR) + except (Exception, asyncio.CancelledError) as error: + logger.debug("Could not report response failure; error_type=%s", type(error).__name__) diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/response_telemetry.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/response_telemetry.py new file mode 100644 index 000000000..adc7b7f8d --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/response_telemetry.py @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Privacy-safe Hosted Agent response telemetry.""" + +from __future__ import annotations + +import time +from contextlib import AbstractContextManager + +from opentelemetry import trace +from opentelemetry.trace import Span, Status, StatusCode + +TRACER_NAME = "VoiceHostedAgent.Response" +RESPONSE_DURATION_ATTRIBUTE = "voice_agent.hosted_agent.response_duration_ms" +RESPONSE_ID_ATTRIBUTE = "gen_ai.response.id" +INPUT_ITEM_ID_ATTRIBUTE = "voice_agent.input.item_id" +OUTCOME_ATTRIBUTE = "voice_agent.hosted_agent.outcome" + +_ERROR_OUTCOMES = frozenset({"abandoned", "error", "timeout", "transport_error"}) + +_tracer = trace.get_tracer(TRACER_NAME) + + +class ResponseTelemetry: + """Own one response span from user.message receipt through response.done send.""" + + def __init__(self, response_id: str, input_item_id: str, received_at: float) -> None: + self._received_at = received_at + self._span = _tracer.start_span( + "process_response", + attributes={ + RESPONSE_ID_ATTRIBUTE: response_id, + INPUT_ITEM_ID_ATTRIBUTE: input_item_id, + }, + ) + self._ended = False + + def activate(self) -> AbstractContextManager[Span]: + """Make this response current while creating its background task.""" + return trace.use_span(self._span, end_on_exit=False) + + def record_response_done_sent(self) -> None: + """Record duration after response.done send completes, then end successfully.""" + self.record_outcome("response") + + def record_outcome(self, outcome: str) -> None: + """Record one sanitized terminal outcome and end the span exactly once.""" + if self._ended: + return + self._span.set_attribute( + RESPONSE_DURATION_ATTRIBUTE, + (time.monotonic() - self._received_at) * 1000, + ) + self._span.set_attribute(OUTCOME_ATTRIBUTE, outcome) + self._span.set_status( + Status(StatusCode.ERROR if outcome in _ERROR_OUTCOMES else StatusCode.OK) + ) + self.end() + + def record_failure(self, exception: BaseException) -> None: + """Mark response processing failed without recording exception content.""" + error_type = f"{type(exception).__module__}.{type(exception).__qualname__}" + self._span.set_attribute("error.type", error_type) + self.record_outcome("error") + + def end(self) -> None: + """End the span exactly once.""" + if self._ended: + return + self._ended = True + self._span.end() diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/route_worker_agent.md b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/route_worker_agent.md new file mode 100644 index 000000000..ebdf6a41c --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/route_worker_agent.md @@ -0,0 +1,50 @@ +# Router + Worker Agent Design + +## Overview + +A two-agent architecture where the **Router** handles user interaction using an asynchronous +Chat Completions call, and the **Worker** retains the background Responses/tool loop. +Hosted ingress is now the CE WebSocket protocol, not the Responses HTTP protocol. + +## Router Agent + +- Receives every user message and makes a single LLM call with `tool_choice: required`. +- Must call exactly one of its meta-tools per turn — no free-text output path. +- Meta-tools: + - `respond_directly` — greetings, chat, clarifications, delivering results + - `start_task` — delegate actionable field-ops work to the Worker + - `check_task_status` — report progress of a running task + - `cancel_task` — cancel a running task + - `get_task_result` — retrieve a completed task's result +- Guards against duplicate tasks: if a task is already active, `start_task` returns the existing task instead of creating a new one. +- Injected context each turn: running tasks, recently completed results, previously delivered results. + +## Worker Agent + +- Runs in an independently retained `asyncio.create_task`. +- Executes a multi-round tool-calling loop (up to 5 rounds) using domain tools: + - `search_site_specs` — site specification lookup + - `search_work_iq` — Teams/Outlook/People search + - `get_repair_procedure` — step-by-step maintenance procedures + - `analyze_document` — document understanding and extraction +- Writes results into the shared `Task` object (never talks to the user directly). +- Supports cancellation between rounds via `cancel_event`. +- Survives foreground interruption within a connection. Session end/disconnect requests + cooperative cancellation; shutdown joins retained Workers after their in-flight call settles. + +## Task Store + +- In-memory registry tracking task lifecycle: `QUEUED → RUNNING → COMPLETED → DELIVERED`. +- Explicit task IDs (including paraphrases) are marked delivered after a successful CE + `response.done` send, not during result collection or by substring matching. +- `delivered_tasks()` exposes history so the Router can recall past results on follow-up questions. + +## Streaming & Wait Behavior + +- Handler streams: acknowledgment → wait for active tasks → deliver results. +- While waiting, optional status updates run every five seconds, up to five times, with a + one-second model deadline. A newly ready result takes precedence over optional status speech. +- Foreground cancellation stops that CE response. Workers continue until they finish or the + business task/session requests cancellation; another turn on the same connection can deliver results. +- Task state and bounded caller-heard history are isolated per connection; they are not durable + or resumed across disconnected sockets. diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/router_agent.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/router_agent.py new file mode 100644 index 000000000..0455092ad --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/router_agent.py @@ -0,0 +1,285 @@ +"""Router Agent — function-routing pattern with tool_choice: required. + +Every router turn MUST produce exactly one tool call. No free-text path exists. +This eliminates "hallucinated action" where the LLM says "looking into it" without +actually delegating. + +Tools: + respond_directly — for chat, greetings, delivering completed results + start_task — delegate field-ops work to worker (includes ack_message) + check_task_status / cancel_task / get_task_result — meta-operations +""" + +import json +import logging + +from task_store import Task, TaskStatus, TaskStore + +logger = logging.getLogger(__name__) +TASK_UNAVAILABLE_MESSAGE = "No task found (it may have expired or been evicted)." + +# ── Router Tools ────────────────────────────────────────────────────────────── +# The caller also sets parallel_tool_calls=False and validates exactly one call. + +ROUTER_TOOLS = [ + { + "type": "function", + "name": "respond_directly", + "description": ( + "Respond to the user directly without delegating. " + "Use for: greetings, chat, clarifications, delivering completed results, " + "or any response that does NOT require background tool execution." + ), + "parameters": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The spoken response to the user. Keep concise for voice.", + }, + "delivered_task_ids": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "IDs of retained completed or already-delivered tasks whose results this " + "message conveys, including paraphrases and translations. " + "Empty for unrelated chat." + ), + }, + }, + "required": ["message", "delivered_task_ids"], + }, + }, + { + "type": "function", + "name": "start_task", + "description": ( + "Delegate a field-ops request to the background worker agent. " + "Use for: site specs lookup, Work IQ search, repair procedures, " + "document analysis — anything requiring tool execution." + ), + "parameters": { + "type": "object", + "properties": { + "request_summary": { + "type": "string", + "description": "Clear summary of what the user needs done", + }, + "ack_message": { + "type": "string", + "description": ( + "Very short spoken acknowledgment, max 5-6 words (e.g. 'Looking that up" + ".' or 'On it.')" + ), + }, + }, + "required": ["request_summary", "ack_message"], + }, + }, + { + "type": "function", + "name": "check_task_status", + "description": ( + "Check progress of a running or completed task. " + "Use when the user asks about status or progress." + ), + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task ID, or 'latest' for most recent task", + }, + "ack_message": { + "type": "string", + "description": "Brief spoken response about the status", + }, + }, + "required": ["task_id", "ack_message"], + }, + }, + { + "type": "function", + "name": "cancel_task", + "description": "Cancel a running or queued task.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task ID to cancel, or 'latest'", + }, + "ack_message": { + "type": "string", + "description": "Brief spoken confirmation to the user", + }, + }, + "required": ["task_id", "ack_message"], + }, + }, + { + "type": "function", + "name": "get_task_result", + "description": ( + "Retrieve the final result of a completed task. " + "Use when a task has finished and you need to relay the answer." + ), + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task ID, or 'latest'", + }, + }, + "required": ["task_id"], + }, + }, +] + +# Force the model to always call a tool (no free-text path) +ROUTER_TOOL_CHOICE = "required" + +ROUTER_SYSTEM_PROMPT = ( + "You are the front-desk assistant for field operations technicians at M" + "icrosoft data centers.\n\nYou MUST call exactly one function per turn. Y" + "ou cannot respond with plain text.\nWhen respond_directly conveys compl" + "eted results, include their exact task IDs in\ndelivered_task_ids. Incl" + "ude only results actually conveyed in message; use [] for\nunrelated ch" + "at, acknowledgments, or status-only replies.\n\nFUNCTION SELECTION:\n- re" + "spond_directly: greetings, chat, clarifications, capability questions," + " delivering completed results, acknowledging user, answering from conv" + "ersation history, ANY question unrelated to field-ops\n- start_task: us" + "er provides a COMPLETE, ACTIONABLE field-ops request (they've given en" + "ough info to act on)\n- check_task_status: user asks about progress of " + "a running task\n- cancel_task: user wants to stop a task\n- get_task_res" + "ult: retrieve a finished task's result that hasn't been shown yet\n\nWHE" + 'N TO USE start_task vs respond_directly:\n- "Analyze document 12345" → ' + 'start_task (actionable: document ID is provided)\n- "Can you analyze a ' + 'document?" → respond_directly (capability question, no document specif' + 'ied)\n- "Search site specs for Quincy North" → start_task (actionable: ' + 'site name provided)\n- "Can you look stuff up?" → respond_directly (no ' + 'specific request)\n- "How old are you?" / "What\'s the weather?" → respo' + 'nd_directly (unrelated to field-ops)\n- "Great" / "Thanks" → respond_di' + "rectly (acknowledgment)\n\nRULES:\n1. For greetings/chat/thanks/acknowled" + 'gments ("Great", "OK", "Thank you", "Got it", "Perfect", etc.) → ALWAY' + "S call respond_directly. NEVER re-trigger a task for these.\n2. For que" + "stions UNRELATED to field operations (personal questions, general know" + "ledge, small talk) → ALWAYS call respond_directly, regardless of any r" + "unning tasks.\n3. Only call start_task when the user provides a SPECIFI" + "C, ACTIONABLE request with enough parameters to execute (e.g., a docum" + "ent ID, a site name, a procedure type). If key information is missing," + " call respond_directly to ask for it.\n4. Do NOT call start_task if a r" + 'unning task already covers the same request (check "Running tasks" con' + "text).\n5. Do NOT call start_task if the user is referring to a result " + "that was ALREADY delivered. If the result is in conversation history o" + 'r in "Recently delivered" context, call respond_directly and re-state ' + 'it. If result bodies were omitted, use get_task_result instead.\n6. If "Recently completed" ' + "results exist in context, call respond_" + "directly and include those results naturally in your message.\n7. If th" + "e user asks to recall/replay/repeat a previous result, look at convers" + 'ation history or "Recently delivered" context and call respond_directl' + "y with that information. If only a task index is available, use get_task_result; " + "never infer missing result details.\n8. Match the user's language. Keep messages c" + "oncise (voice-enabled, hands-free).\n9. When the user's message has BOT" + "H chat and a NEW actionable field-ops request, call start_task (the ac" + "k_message handles the conversational part).\n\nIMPORTANT: A message like" + ' "Great", "Thanks", "OK" after a delivered result is just the user ack' + "nowledging — respond with something brief and friendly. Do NOT re-run " + "the task.\nIMPORTANT: If there is a running task and the user says some" + "thing UNRELATED, just respond with respond_directly. Do NOT create a n" + "ew task.\n\nSTYLE: concise, helpful, technical when needed. The technici" + "an is working hands-free." +) + + +# ── Router Tool Execution ───────────────────────────────────────────────────── + + +def execute_router_tool(name: str, arguments: dict, store: TaskStore) -> tuple[str, Task | None]: + """Execute a router meta-tool. Returns (result_json, new_task_or_none). + + All operations here are instant (no I/O, no blocking). + """ + if name == "respond_directly": + return json.dumps({"delivered": True}), None + + if name == "start_task": + # Guard: don't create a duplicate task if one is already active + active = store.active_tasks() + if active: + existing = active[-1] # most recent active task + return json.dumps( + { + "task_id": existing.task_id, + "status": existing.status.value, + "note": "Task already running — not creating duplicate", + "query": existing.query, + } + ), None # Return None → no new worker spawned + task = store.create_task(arguments["request_summary"]) + return json.dumps({"task_id": task.task_id, "status": "queued"}), task + + if name == "check_task_status": + task = store.get(arguments["task_id"]) + if not task: + return json.dumps({"error": TASK_UNAVAILABLE_MESSAGE}), None + return json.dumps( + { + "task_id": task.task_id, + "status": task.status.value, + "current_tool": task.current_tool, + "rounds_completed": task.rounds_completed, + "query": task.query, + } + ), None + + if name == "cancel_task": + success = store.cancel(arguments["task_id"]) + task = store.get(arguments["task_id"]) + return json.dumps( + { + "cancelled": success, + "task_id": task.task_id if task else None, + } + ), None + + if name == "get_task_result": + task = store.get(arguments["task_id"]) + if not task: + return json.dumps({"error": TASK_UNAVAILABLE_MESSAGE}), None + if task.status in (TaskStatus.COMPLETED, TaskStatus.DELIVERED): + return json.dumps({"task_id": task.task_id, "result": task.result}), None + return json.dumps( + { + "error": "Task not completed yet", + "task_id": task.task_id, + "status": task.status.value, + } + ), None + + return json.dumps({"error": f"Unknown router tool: {name}"}), None + + +def build_task_context(store: TaskStore) -> str | None: + """Build a context string about active tasks for the router prompt.""" + lines = [] + for t in store.active_tasks(): + lines.append( + f"RUNNING: task {t.task_id} — '{t.query}' " + f"(round {t.rounds_completed + 1}, tool: {t.current_tool or 'thinking'})" + ) + return "\n".join(lines) if lines else None + + +def build_delivered_context(store: TaskStore) -> str | None: + """Build a context string of recently delivered task results. + + This helps the router recall results that were already shown to the user, + so it can respond to follow-ups like 'what was the result?' without re-running. + """ + lines = [] + for t in store.delivered_tasks(): + # The caller budgets the entire task context and requests lookup if it does not fit. + lines.append(f"DELIVERED: task {t.task_id} — '{t.query}' → {t.result or ''}") + return "\n".join(lines) if lines else None diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/scripts/smoke_test.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/scripts/smoke_test.py new file mode 100644 index 000000000..8f4d939fb --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/scripts/smoke_test.py @@ -0,0 +1,96 @@ +"""Model-backed protocol smoke; no audio or cloud resources are provisioned.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import uuid +from datetime import UTC, datetime + +import websockets +from websockets.exceptions import ConnectionClosed + + +async def run(uri: str, text: str) -> None: + async with websockets.connect(uri, open_timeout=30, close_timeout=10) as socket: + + async def send(kind: str, **fields: object) -> None: + await socket.send( + json.dumps( + { + "type": kind, + "id": f"m_{uuid.uuid4().hex}", + "ts": datetime.now(UTC).isoformat(), + **fields, + } + ) + ) + + await send( + "session.start", + protocol_version="1.0", + reconnect=False, + response_timeouts={ + "first_output_ms": 30000, + "idle_ms": 30000, + "max_duration_ms": 120000, + }, + ) + try: + ready = json.loads(await asyncio.wait_for(socket.recv(), 30)) + if ready["type"] != "session.ready": + raise RuntimeError("The conversation engine did not accept the session") + await send( + "user.message", item_id="in_smoke", content=[{"type": "input_text", "text": text}] + ) + frames: list[str] = [] + response_id = None + item_id = None + accumulated = "" + text_done = False + while True: + frame = json.loads(await asyncio.wait_for(socket.recv(), 90)) + kind = frame["type"] + frames.append(kind) + if response_id is None: + if kind != "response.created" or frame.get("in_reply_to") != ["in_smoke"]: + raise RuntimeError("Expected a correlated response.created") + response_id = frame["response_id"] + continue + if frame.get("response_id") != response_id: + raise RuntimeError("Response ID changed during generation") + if kind in ("response.output_text.delta", "response.output_text.done"): + if text_done: + raise RuntimeError("Output arrived after text completion") + item_id = item_id or frame["item_id"] + if frame["item_id"] != item_id: + raise RuntimeError("Output item ID changed") + if kind == "response.output_text.delta": + accumulated += frame["delta"] + else: + if not accumulated or frame["text"] != accumulated: + raise RuntimeError("Completed text does not match streamed text") + text_done = True + elif kind == "response.done" and text_done: + print(json.dumps({"frames": frames, "text": accumulated}, ensure_ascii=True)) + return + else: + raise RuntimeError(f"Unexpected CE frame type: {kind}") + finally: + try: + await send("session.end", reason="validation_complete") + except ConnectionClosed: + pass + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--uri", default="ws://127.0.0.1:8088/voice_conversation_engine") + parser.add_argument("--text", default="Hello!") + options = parser.parse_args() + asyncio.run(run(options.uri, options.text)) + + +if __name__ == "__main__": + main() diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/state.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/state.py new file mode 100644 index 000000000..8e14f52fa --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/state.py @@ -0,0 +1,249 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Bounded application state for one Voice Live Bridge connection.""" + +from __future__ import annotations + +import asyncio +import hashlib +from collections.abc import AsyncIterable +from contextlib import nullcontext +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from azure.ai.agentserver.invocations.voice import Session + +from response_telemetry import ResponseTelemetry + +MAX_TRACKED_PLAYBACK_RESPONSES = 64 +MAX_MESSAGE_CHARACTERS = 8_000 +MAX_OUTPUT_BYTES = 512 * 1024 +MAX_ACTIVE_RESPONSES = 8 +MAX_TERMINAL_SENDS = 8 +MAX_PENDING_PROACTIVE = 8 +MAX_SEEN_INPUTS = 4096 + + +class InputClaim(Enum): + INACTIVE = "inactive" + CLAIMED = "claimed" + DUPLICATE = "duplicate" + CAPACITY_EXCEEDED = "capacity_exceeded" + + +class SessionPhase(Enum): + AWAITING_START = "awaiting_start" + STARTING = "starting" + ACTIVE = "active" + REJECTED = "rejected" + TERMINATING = "terminating" + + +class OperationOutcome(Enum): + """Application-known response outcome used for local cleanup.""" + + RESPONSE = "response" + NONE = "none" + CANCELLED = "cancelled" + TIMEOUT = "timeout" + ERROR = "error" + END_CALL = "end_call" + TRANSPORT_ERROR = "transport_error" + ABANDONED = "abandoned" + + +class InputOrigin(Enum): + """Origin of one application input decision.""" + + USER = "user" + NO_INPUT = "no_input" + PROACTIVE = "proactive" + + +class ApplicationTurn: + """Minimal application-owned turn bookkeeping for AgentServer 1.1.0.""" + + def __init__(self) -> None: + self.is_completed = False + + def activate(self) -> Any: + return nullcontext() + + def complete(self, **fields: Any) -> None: + del fields + self.is_completed = True + + +@dataclass(frozen=True, slots=True) +class ProactiveRequest: + text: str + + +@dataclass(slots=True) +class ResponseOperation: + session: Session + state: "SessionState" + response_id: str + input_ids: tuple[str, ...] + turn: ApplicationTurn + response_started: bool + task: asyncio.Task[None] | None = None + output_iterator: AsyncIterable[Any] | None = None + previous_resolution: asyncio.Future[None] | None = None + resolution_done: asyncio.Future[None] | None = None + running: bool = False + closing: bool = False + finalizing: bool = False + cancellation_requested: bool = False + terminal_sent: bool = False + generation_acquired: bool = False + terminal_event: asyncio.Event | None = None + output_item_count: int = 0 + capacity_released: bool = False + outcome: OperationOutcome | None = None + telemetry: ResponseTelemetry | None = None + + def select_outcome(self, outcome: OperationOutcome) -> None: + """Keep the first application-known terminal outcome.""" + if self.outcome is None: + self.outcome = outcome + + def release_generation(self) -> None: + if self.generation_acquired: + self.generation_acquired = False + self.state.generation_gate.release() + + def request_stop(self, outcome: OperationOutcome) -> None: + if self.terminal_sent or self.cancellation_requested: + return + self.cancellation_requested = True + self.select_outcome(outcome) + self.release_generation() + if self.terminal_event is not None: + self.terminal_event.set() + if self.running and not self.closing and not self.finalizing and self.task is not None: + self.task.cancel() + + def complete(self, fallback: OperationOutcome) -> None: + """Complete application-owned response telemetry exactly once.""" + self.select_outcome(fallback) + if self.telemetry is not None: + assert self.outcome is not None + self.telemetry.record_outcome(self.outcome.value) + + def release_capacity(self) -> None: + """Allow a new response to start before this task's done callback runs.""" + self.capacity_released = True + + +@dataclass(slots=True) +class SessionState: + heard_text_by_response: dict[str, str] = field(default_factory=dict) + responses: dict[str, ResponseOperation] = field(default_factory=dict) + input_responses: dict[str, str] = field(default_factory=dict) + proactive_requests: dict[str, ProactiveRequest] = field(default_factory=dict) + seen_inputs: set[bytes] = field(default_factory=set) + phase: SessionPhase = SessionPhase.AWAITING_START + generation_gate: asyncio.Lock = field(default_factory=asyncio.Lock) + input_resolution_tail: asyncio.Future[None] | None = None + + def track_response(self, response_id: str) -> None: + """Retain bounded playback state after response.done for late interruption events.""" + self.heard_text_by_response.setdefault(response_id, "") + while len(self.heard_text_by_response) > MAX_TRACKED_PLAYBACK_RESPONSES: + self.heard_text_by_response.pop(next(iter(self.heard_text_by_response))) + + def record_heard_text(self, response_id: str, heard_text: str) -> None: + """Synchronize only responses this connection has actually tracked.""" + if response_id not in self.heard_text_by_response and response_id not in self.responses: + return + # Playback describes response output, not user input. Bound both the UTF-8 + # payload and temporary allocation; malformed escapes must not block cancellation. + encoded = heard_text[:MAX_OUTPUT_BYTES].encode("utf-8", errors="replace") + bounded = encoded[:MAX_OUTPUT_BYTES].decode("utf-8", errors="ignore") + self.track_response(response_id) + self.heard_text_by_response[response_id] = bounded + + @property + def terminating(self) -> bool: + return self.phase is SessionPhase.TERMINATING + + @terminating.setter + def terminating(self, value: bool) -> None: + if value: + self.phase = SessionPhase.TERMINATING + + @property + def active(self) -> bool: + return self.phase is SessionPhase.ACTIVE + + @property + def active_response_count(self) -> int: + """Count operations that still occupy one response-capacity slot.""" + return sum(not operation.capacity_released for operation in self.responses.values()) + + @property + def terminal_send_count(self) -> int: + """Count released operations whose terminal send has not finalized.""" + return sum(operation.capacity_released for operation in self.responses.values()) + + def can_start_response(self) -> bool: + """Return whether this active connection can reserve another response slot.""" + return ( + self.active + and self.active_response_count < MAX_ACTIVE_RESPONSES + and self.terminal_send_count < MAX_TERMINAL_SENDS + ) + + def try_begin_start(self) -> bool: + if self.phase is not SessionPhase.AWAITING_START: + return False + self.phase = SessionPhase.STARTING + return True + + def activate(self) -> None: + if self.phase is SessionPhase.STARTING: + self.phase = SessionPhase.ACTIVE + + def reject(self) -> None: + if self.phase is SessionPhase.STARTING: + self.phase = SessionPhase.REJECTED + + def claim_input(self, item_id: str) -> InputClaim: + """Claim an input once without forgetting IDs during this connection.""" + if not self.active: + return InputClaim.INACTIVE + digest = hashlib.sha256(item_id.encode("utf-8")).digest() + if digest in self.seen_inputs: + return InputClaim.DUPLICATE + if len(self.seen_inputs) >= MAX_SEEN_INPUTS: + return InputClaim.CAPACITY_EXCEEDED + self.seen_inputs.add(digest) + return InputClaim.CLAIMED + + +class SessionStore: + """Own the mapping from transport sessions to connection state.""" + + def __init__(self) -> None: + self._states: dict[Session, SessionState] = {} + + def get_or_create(self, session: Session) -> SessionState: + """Return existing state or create state for a new connection.""" + return self._states.setdefault(session, SessionState()) + + def active(self, session: Session) -> SessionState | None: + """Return active state, or None for early and late callbacks.""" + state = self._states.get(session) + return state if state is not None and state.active else None + + def remove(self, session: Session) -> SessionState | None: + """Remove and return one connection state.""" + return self._states.pop(session, None) + + def drain(self) -> tuple[SessionState, ...]: + """Atomically detach a snapshot of all connection states.""" + states = tuple(self._states.values()) + self._states.clear() + return states diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/task_store.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/task_store.py new file mode 100644 index 000000000..ac6eaa385 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/task_store.py @@ -0,0 +1,114 @@ +"""Task Store — shared state between Router and Worker agents. + +Tracks background tasks: their status, current progress, results, and cancellation. +In production, replace the in-memory dict with Redis or Cosmos DB. +""" + +import asyncio +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum + +MAX_RETAINED_TASKS = 16 +MAX_TASK_QUERY_CHARACTERS = 8_000 +MAX_TASK_RESULT_CHARACTERS = 8_000 +TASK_RETENTION_SECONDS = 300 + + +class TaskStatus(Enum): + QUEUED = "queued" + RUNNING = "running" + COMPLETED = "completed" + DELIVERED = "delivered" + CANCELLED = "cancelled" + FAILED = "failed" + + +@dataclass +class Task: + task_id: str + query: str + status: TaskStatus = TaskStatus.QUEUED + current_tool: str | None = None + rounds_completed: int = 0 + result: str | None = None + cancel_event: asyncio.Event = field(default_factory=asyncio.Event) + created_at: float = field(default_factory=time.time) + finished_at: float | None = None + + +class TaskStore: + """In-memory task registry. One instance per physical CE connection.""" + + def __init__(self): + self._tasks: dict[str, Task] = {} + self._latest_task_id: str | None = None + + def create_task(self, query: str) -> Task: + if len(query) > MAX_TASK_QUERY_CHARACTERS: + raise ValueError("Task query exceeds the sample's size limit") + task_id = uuid.uuid4().hex[:8] + task = Task(task_id=task_id, query=query) + self.cleanup() + if len(self._tasks) >= MAX_RETAINED_TASKS: + oldest = min( + (t for t in self._tasks.values() + if t.status not in (TaskStatus.QUEUED, TaskStatus.RUNNING)), + key=lambda t: t.finished_at if t.finished_at is not None else t.created_at, + default=None, + ) + if oldest is None: + raise ValueError("Task store is full of active work") + del self._tasks[oldest.task_id] + self._tasks[task_id] = task + self._latest_task_id = task_id + return task + + def get(self, task_id: str) -> Task | None: + if task_id == "latest": + return self._get_latest() + return self._tasks.get(task_id) + + def cancel(self, task_id: str) -> bool: + task = self.get(task_id) + if task and task.status in (TaskStatus.QUEUED, TaskStatus.RUNNING): + task.cancel_event.set() + return True + return False + + def active_tasks(self) -> list[Task]: + return [ + t for t in self._tasks.values() if t.status in (TaskStatus.QUEUED, TaskStatus.RUNNING) + ] + + def completed_tasks(self) -> list[Task]: + return [t for t in self._tasks.values() if t.status == TaskStatus.COMPLETED] + + def delivered_tasks(self) -> list[Task]: + """Return tasks that have been delivered to the user (for context recall).""" + return [t for t in self._tasks.values() if t.status == TaskStatus.DELIVERED] + + def terminal_tasks(self) -> list[Task]: + """Expose sanitized failure/cancellation status to subsequent Router turns.""" + return [ + task + for task in self._tasks.values() + if task.status in (TaskStatus.FAILED, TaskStatus.CANCELLED) + ] + + def cleanup(self): + """Prune terminal outcomes, including undelivered results, at turn/admission time.""" + cutoff = time.time() - TASK_RETENTION_SECONDS + to_remove = [ + tid + for tid, t in self._tasks.items() + if t.status not in (TaskStatus.QUEUED, TaskStatus.RUNNING) + and (t.finished_at if t.finished_at is not None else t.created_at) < cutoff + ] + for tid in to_remove: + del self._tasks[tid] + + def _get_latest(self) -> Task | None: + # An expired latest reference must not silently return an older task. + return self._tasks.get(self._latest_task_id) diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_conversation_engine.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_conversation_engine.py new file mode 100644 index 000000000..9e1ebe2e4 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_conversation_engine.py @@ -0,0 +1,814 @@ +"""Model-free tests through the real SDK CE dispatcher and original Worker loop.""" + +from __future__ import annotations + +import asyncio +import json +import threading +import uuid +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + +import pytest +from azure.ai.agentserver.invocations.voice import ( + ResponseDone, + ResponseOutputTextDelta, +) +from starlette.testclient import TestClient + +from agent_logic import Conversation, ForegroundBackgroundAgent, Turn, text_chunks +from conversation_runtime import ConversationRuntime, HistorySession, create_app +from scripts.smoke_test import run as run_smoke +from state import MAX_TERMINAL_SENDS, SessionState +from task_store import TaskStatus + + +def tool(name: str, **arguments: Any) -> Any: + if name == "respond_directly": + arguments.setdefault("delivered_task_ids", []) + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + tool_calls=[ + SimpleNamespace( + function=SimpleNamespace( + name=name, + arguments=json.dumps(arguments), + ) + ) + ], + ) + ) + ] + ) + + +class Chat: + def __init__(self, *steps: Any) -> None: + self.steps = list(steps) + self.requests: list[dict[str, Any]] = [] + + async def create(self, **kwargs: Any) -> Any: + self.requests.append(kwargs) + step = self.steps.pop(0) + if isinstance(step, Exception): + raise step + if callable(step): + return await step(kwargs) + return step + + +class Responses: + def __init__(self, *steps: Any) -> None: + self.steps = list(steps) + self.requests: list[dict[str, Any]] = [] + + def create(self, **kwargs: Any) -> Any: + self.requests.append(kwargs) + step = self.steps.pop(0) + return step() if callable(step) else step + + +def answer(text: str, status: str = "completed", output: list | None = None) -> Any: + return SimpleNamespace(status=status, output_text=text, output=output or []) + + +def frame(kind: str, **fields: Any) -> dict: + return {"type": kind, "id": f"m_{uuid.uuid4().hex}", "ts": "2026-11-10T00:00:00Z", **fields} + + +def start(ws: Any, protocol: str = "1.0") -> dict: + ws.send_json( + frame( + "session.start", + protocol_version=protocol, + reconnect=False, + response_timeouts={ + "first_output_ms": 30000, + "idle_ms": 30000, + "max_duration_ms": 120000, + }, + ) + ) + return ws.receive_json() + + +def send(ws: Any, item_id: str, text: str) -> None: + ws.send_json( + frame("user.message", item_id=item_id, content=[{"type": "input_text", "text": text}]) + ) + + +def until(ws: Any, terminal: str = "response.done") -> list[dict]: + frames = [] + while True: + frame = ws.receive_json() + frames.append(frame) + if frame["type"] == terminal: + return frames + + +def output(frames: list[dict]) -> str: + return "".join( + frame["delta"] for frame in frames if frame["type"] == "response.output_text.delta" + ) + + +def wait_for_server(client: TestClient, predicate: Callable[[], bool]) -> None: + # Client receipt can precede the server's post-send bookkeeping continuation. + async def wait() -> None: + async with asyncio.timeout(2): + for _ in range(200): + if predicate(): + return + await asyncio.sleep(0.01) + assert predicate(), "Server bookkeeping did not settle" + + client.portal.call(wait) + + +@pytest.mark.parametrize("route", ["/voice_conversation_engine", "/invocations_ws"]) +def test_direct_router_response_and_history(route: str) -> None: + chat = Chat( + tool("respond_directly", message="Hello."), tool("respond_directly", message="Second.") + ) + app = create_app( + ForegroundBackgroundAgent(chat, Responses(), "test"), configure_observability=None + ) + with TestClient(app) as client, client.websocket_connect(route) as ws: + assert start(ws)["type"] == "session.ready" + send(ws, "in_1", "First") + first = until(ws) + assert output(first) == "Hello." + assert first[0]["in_reply_to"] == ["in_1"] + send(ws, "in_2", "Second") + until(ws) + assert client.post("/responses", json={}).status_code == 404 + messages = chat.requests[1]["messages"] + assert {"role": "assistant", "content": "Hello."} in messages + assert chat.requests[0]["parallel_tool_calls"] is False + assert app.state.voice_runtime.conversations == {} + + +def test_handshake_rejects_other_message_protocol() -> None: + chat = Chat() + app = create_app( + ForegroundBackgroundAgent(chat, Responses(), "test"), configure_observability=None + ) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + assert start(ws, "2.0")["type"] == "session.rejected" + assert not chat.requests + + +def test_connections_do_not_share_history_or_tasks() -> None: + chat = Chat( + tool("respond_directly", message="First private answer"), + tool("respond_directly", message="Second private answer"), + ) + app = create_app( + ForegroundBackgroundAgent(chat, Responses(), "test"), configure_observability=None + ) + with TestClient(app) as client: + with client.websocket_connect("/voice_conversation_engine") as first: + start(first) + send(first, "in_same", "First private question") + until(first) + with client.websocket_connect("/voice_conversation_engine") as second: + start(second) + send(second, "in_same", "Second private question") + until(second) + assert len(app.state.voice_runtime.conversations) == 2 + assert "First private" not in repr(chat.requests[1]) + + +def test_real_worker_executes_domain_tool_and_delivers(monkeypatch: pytest.MonkeyPatch) -> None: + import worker_agent + + seen = [] + + def execute(name: str, arguments: dict) -> str: + seen.append((name, arguments)) + return '{"panel": "OS2"}' + + monkeypatch.setattr(worker_agent, "_execute_tool", execute) + call = SimpleNamespace( + type="function_call", + id="fc_1", + call_id="call_1", + name="search_site_specs", + arguments='{"site_name":"Quincy"}', + ) + responses = Responses(answer("", output=[call]), answer("Use OS2 on panel 1.")) + chat = Chat(tool("start_task", request_summary="Find Quincy panels", ack_message="On it.")) + app = create_app( + ForegroundBackgroundAgent(chat, responses, "test"), configure_observability=None + ) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + start(ws) + send(ws, "in_task", "Find Quincy panels") + frames = until(ws) + assert output(frames) == "On it.\n\nUse OS2 on panel 1." + conversation = next(iter(app.state.voice_runtime.conversations.values())) + wait_for_server( + client, lambda: conversation.tasks.get("latest").status is TaskStatus.DELIVERED + ) + assert seen == [("search_site_specs", {"site_name": "Quincy"})] + assert len(responses.requests) == 2 + + +def test_barge_in_reconciles_sent_history() -> None: + chat = Chat( + tool("respond_directly", message="Heard prefix. Unheard suffix."), + tool("respond_directly", message="New answer"), + ) + app = create_app( + ForegroundBackgroundAgent(chat, Responses(), "test"), configure_observability=None + ) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + start(ws) + send(ws, "in_1", "Question") + first = until(ws) + ws.send_json( + frame("user.barge_in", response_id=first[0]["response_id"], heard_text="Heard prefix.") + ) + send(ws, "in_2", "Next question") + until(ws) + assert {"role": "assistant", "content": "Heard prefix."} in chat.requests[1]["messages"] + assert "Unheard suffix" not in repr(chat.requests[1]) + + +def test_timeout_interrupts_router_and_receive_loop_accepts_next_turn() -> None: + started = threading.Event() + cancelled = threading.Event() + + async def blocked(kwargs: dict) -> Any: + started.set() + try: + await asyncio.Future() + finally: + cancelled.set() + + chat = Chat(blocked, tool("respond_directly", message="Replacement")) + app = create_app( + ForegroundBackgroundAgent(chat, Responses(), "test"), configure_observability=None + ) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + start(ws) + send(ws, "in_a", "Blocked question") + created = ws.receive_json() + assert started.wait(2) + ws.send_json( + frame("response.timeout", response_id=created["response_id"], stage="first_output") + ) + send(ws, "in_b", "Replacement question") + assert output(until(ws)) == "Replacement" + assert cancelled.is_set() + assert not any(message["role"] == "assistant" for message in chat.requests[1]["messages"][:-1]) + + +def test_router_errors_are_protocol_errors_not_spoken_diagnostics() -> None: + chat = Chat(RuntimeError("PRIVATE_PROVIDER_DETAIL")) + app = create_app( + ForegroundBackgroundAgent(chat, Responses(), "test"), configure_observability=None + ) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + start(ws) + send(ws, "in_failure", "Question") + frames = until(ws, "error") + assert not output(frames) + assert "PRIVATE_PROVIDER_DETAIL" not in repr(frames) + + +class RecordingSession: + def __init__(self, fail: bool = False) -> None: + self.fail = fail + + async def send(self, message: Any) -> None: + if self.fail: + raise RuntimeError("backpressure or disconnected transport") + + +async def test_delivery_uses_explicit_task_ids_not_matching_text() -> None: + conversation = Conversation() + first, second = [conversation.tasks.create_task(str(i)) for i in range(2)] + first.status = second.status = TaskStatus.COMPLETED + first.result, second.result = "1", "10" + turn = Turn("r_test", {second.task_id}) + state = SessionState() + state.try_begin_start() + state.activate() + session = HistorySession(RecordingSession(), state, conversation, turn) + await session.send( + ResponseOutputTextDelta( + response_id=turn.response_id, + item_id="out", + delta="The second answer is ten.", + ) + ) + assert first.status is second.status is TaskStatus.COMPLETED + await session.send(ResponseDone(response_id=turn.response_id)) + assert second.status is TaskStatus.DELIVERED + assert first.status is TaskStatus.COMPLETED + + +async def test_failed_send_does_not_commit_history_or_delivery() -> None: + conversation = Conversation() + task = conversation.tasks.create_task("Query") + task.status = TaskStatus.COMPLETED + turn = Turn("r_test", {task.task_id}) + state = SessionState() + state.try_begin_start() + state.activate() + session = HistorySession(RecordingSession(fail=True), state, conversation, turn) + with pytest.raises(RuntimeError): + await session.send( + ResponseOutputTextDelta( + response_id=turn.response_id, + item_id="out", + delta="NEVER SENT", + ) + ) + with pytest.raises(RuntimeError): + await session.send(ResponseDone(response_id=turn.response_id)) + assert not conversation.history + assert not session.first_text_pending + assert task.status is TaskStatus.COMPLETED + + +@pytest.mark.parametrize("status", ["failed", "incomplete"]) +async def test_worker_unsuccessful_outcomes_are_not_delivered(status: str) -> None: + from worker_agent import run_worker + + task = Conversation().tasks.create_task("Query") + await run_worker(task, [], Responses(answer("PRIVATE_PARTIAL", status)), "test") + assert task.status is TaskStatus.FAILED + assert "PRIVATE" not in task.result + + +async def test_cancellation_settling_during_ack_is_reported() -> None: + conversation = Conversation() + task = conversation.tasks.create_task("Query") + task.status = TaskStatus.RUNNING + chat = Chat(tool("check_task_status", task_id=task.task_id, ack_message="Still working.")) + agent = ForegroundBackgroundAgent(chat, Responses(), "test") + stream = agent.respond(conversation, Turn("r_status"), "Status?") + assert await anext(stream) == "Still working." + task.status = TaskStatus.CANCELLED + rest = "".join([chunk async for chunk in stream]) + assert "cancelled" in rest + + +async def test_shutdown_remains_owned_when_joining_callback_is_cancelled() -> None: + gate = asyncio.Event() + agent = ForegroundBackgroundAgent(Chat(), Responses(), "test") + worker = asyncio.create_task(gate.wait()) + agent.workers.add(worker) + worker.add_done_callback(agent.workers.discard) + runtime = ConversationRuntime(agent) + first_join = asyncio.create_task(runtime.close()) + await asyncio.sleep(0) + first_join.cancel() + with pytest.raises(asyncio.CancelledError): + await first_join + second_join = asyncio.create_task(runtime.close()) + await asyncio.sleep(0) + assert not second_join.done() + assert not worker.cancelled() + gate.set() + await asyncio.wait_for(second_join, 2) + + +def test_worker_survives_barge_in_and_result_is_delivered_next_turn() -> None: + started = threading.Event() + release = threading.Event() + + def blocked() -> Any: + started.set() + assert release.wait(5) + return answer("Worker result") + + responses = Responses(blocked) + chat = Chat( + tool("start_task", request_summary="Find panels", ack_message="Looking."), + tool("respond_directly", message="Checking."), + ) + app = create_app( + ForegroundBackgroundAgent(chat, responses, "test"), configure_observability=None + ) + try: + with ( + TestClient(app) as client, + client.websocket_connect("/voice_conversation_engine") as ws, + ): + start(ws) + send(ws, "in_a", "Find panels") + created = ws.receive_json() + assert ws.receive_json()["type"] == "response.output_text.delta" + assert started.wait(2) + ws.send_json( + frame("user.barge_in", response_id=created["response_id"], heard_text="Look") + ) + send(ws, "in_b", "How is it going?") + replacement = ws.receive_json() + assert replacement["type"] == "response.created" + assert replacement["response_id"] != created["response_id"] + assert ws.receive_json()["type"] == "response.output_text.delta" + conversation = next(iter(app.state.voice_runtime.conversations.values())) + assert not conversation.tasks.get("latest").cancel_event.is_set() + release.set() + frames = until(ws) + assert output(frames) == "\n\nWorker result" + assert all(item.get("response_id") != created["response_id"] for item in frames) + finally: + release.set() + assert len(responses.requests) == 1 + + +async def test_no_input_signals_worker_before_backpressured_terminal_send() -> None: + from azure.ai.agentserver.invocations.voice import EndCall + + gate = asyncio.Event() + end_call_started = asyncio.Event() + + class BackpressuredSession(RecordingSession): + async def send(self, message: Any) -> None: + if isinstance(message, EndCall): + end_call_started.set() + await gate.wait() + + agent = ForegroundBackgroundAgent(Chat(), Responses(), "test") + runtime = ConversationRuntime(agent) + session = BackpressuredSession() + state = runtime._store.get_or_create(session) + state.try_begin_start() + state.activate() + conversation = Conversation() + runtime.conversations[session] = conversation + task = conversation.tasks.create_task("Query") + task.status = TaskStatus.RUNNING + await asyncio.wait_for( + runtime.on_user_no_input( + session, + SimpleNamespace(item_id="in_idle", count=3), + ), + 1, + ) + assert task.cancel_event.is_set() + await asyncio.wait_for(end_call_started.wait(), 1) + assert not gate.is_set() + gate.set() + await runtime.close() + + +async def test_duplicate_no_input_does_not_cancel_business_work() -> None: + agent = ForegroundBackgroundAgent(Chat(), Responses(), "test") + runtime = ConversationRuntime(agent) + session = RecordingSession() + state = runtime._store.get_or_create(session) + state.try_begin_start() + state.activate() + state.claim_input("in_repeat") + conversation = Conversation() + runtime.conversations[session] = conversation + task = conversation.tasks.create_task("Query") + task.status = TaskStatus.RUNNING + await runtime.on_user_no_input(session, SimpleNamespace(item_id="in_repeat", count=3)) + assert not task.cancel_event.is_set() + assert state.active + await runtime.close() + + +async def test_terminal_capacity_cancels_worker_before_backpressured_end_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from azure.ai.agentserver.invocations.voice import EndCall, InputTextPart, UserMessage + + import worker_agent + + entered = threading.Event() + release = threading.Event() + transport_gate = asyncio.Event() + end_call_started = asyncio.Event() + end_calls = [] + tools = [] + + def blocked_model() -> Any: + entered.set() + assert release.wait(5) + call = SimpleNamespace(type="function_call", name="search_site_specs") + return answer("", output=[call]) + + class BackpressuredSession(RecordingSession): + async def send(self, message: Any) -> None: + if isinstance(message, EndCall): + end_calls.append(message) + end_call_started.set() + await transport_gate.wait() + + monkeypatch.setattr(worker_agent, "_execute_tool", lambda *args: tools.append(args)) + responses = Responses(blocked_model) + agent = ForegroundBackgroundAgent(Chat(), responses, "test") + runtime = ConversationRuntime(agent) + session = BackpressuredSession() + state = runtime._store.get_or_create(session) + state.try_begin_start() + state.activate() + conversation = Conversation() + runtime.conversations[session] = conversation + task = conversation.tasks.create_task("Find panels") + agent.start_worker(task, []) + try: + assert await asyncio.to_thread(entered.wait, 2) + for index in range(MAX_TERMINAL_SENDS): + await runtime.on_user_message( + session, + UserMessage( + id=f"m_{index}", + ts="2026-11-10T00:00:00Z", + item_id=f"in_{index}", + content=(InputTextPart(text=" "),), + ), + ) + assert state.active + assert state.terminal_send_count == MAX_TERMINAL_SENDS + assert runtime.conversations[session] is conversation + assert not task.cancel_event.is_set() + + await asyncio.wait_for( + runtime.on_user_message( + session, + UserMessage( + id="m_overflow", + ts="2026-11-10T00:00:00Z", + item_id="in_overflow", + content=(InputTextPart(text=" "),), + ), + ), + 1, + ) + assert not state.active + assert session not in runtime.conversations + assert task.cancel_event.is_set() + await asyncio.wait_for(end_call_started.wait(), 1) + assert not transport_gate.is_set() + + release.set() + await asyncio.wait_for(asyncio.gather(*tuple(agent.workers)), 2) + assert task.status is TaskStatus.CANCELLED + assert not tools + assert len(responses.requests) == 1 + await runtime.on_user_message( + session, + UserMessage( + id="m_late", + ts="2026-11-10T00:00:00Z", + item_id="in_late", + content=(InputTextPart(text="More work"),), + ), + ) + assert not agent.chat.requests + assert len(end_calls) == 1 + finally: + release.set() + transport_gate.set() + await asyncio.wait_for(runtime.close(), 2) + assert not agent.workers + assert not runtime._coordinator._detached_tasks + + +def test_real_worker_finishing_during_router_is_delivered_in_same_turn() -> None: + entered = threading.Event() + release = threading.Event() + + def blocked_model() -> Any: + entered.set() + assert release.wait(5) + return answer("Use OS2 on panel 1.") + + async def finish_during_routing(kwargs: dict) -> Any: + assert "RUNNING:" in repr(kwargs["messages"]) + assert agent.workers + release.set() + await asyncio.wait_for(asyncio.gather(*tuple(agent.workers)), 2) + return tool("respond_directly", message="Checking.") + + chat = Chat( + tool("start_task", request_summary="Find panels", ack_message="Looking."), + finish_during_routing, + tool("respond_directly", message="You're welcome."), + ) + agent = ForegroundBackgroundAgent(chat, Responses(blocked_model), "test") + app = create_app(agent, configure_observability=None) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + try: + start(ws) + send(ws, "in_work", "Find panels") + first = ws.receive_json() + assert ws.receive_json()["delta"] == "Looking." + assert entered.wait(2) + ws.send_json( + frame("user.barge_in", response_id=first["response_id"], heard_text="Looking.") + ) + send(ws, "in_status", "Status?") + frames = until(ws) + expected = "Checking.\n\nUse OS2 on panel 1." + assert output(frames) == expected + assert frames[0]["in_reply_to"] == ["in_status"] + assert all(item.get("response_id") != first["response_id"] for item in frames) + assert sum(item["type"] == "response.done" for item in frames) == 1 + conversation = next(iter(app.state.voice_runtime.conversations.values())) + wait_for_server( + client, lambda: conversation.tasks.get("latest").status is TaskStatus.DELIVERED + ) + send(ws, "in_thanks", "Thanks") + assert output(until(ws)) == "You're welcome." + assert {"role": "assistant", "content": expected} in chat.requests[2]["messages"] + finally: + release.set() + assert not agent.workers + + +@pytest.mark.parametrize("route", ["respond_directly", "check_task_status", "get_task_result"]) +async def test_result_completed_during_router_is_delivered_once(route: str) -> None: + conversation = Conversation() + task = conversation.tasks.create_task("Find panels") + task.status = TaskStatus.RUNNING + + async def finish_during_routing(kwargs: dict) -> Any: + assert "RUNNING:" in repr(kwargs["messages"]) + await asyncio.sleep(0) + task.result = "Use OS2 on panel 1." + task.status = TaskStatus.COMPLETED + if route == "respond_directly": + return tool(route, message="Checking.") + return tool(route, task_id=task.task_id, ack_message="Checking.") + + chat = Chat(finish_during_routing, tool("respond_directly", message="You're welcome.")) + agent = ForegroundBackgroundAgent(chat, Responses(), "test") + turn = Turn("r_finished_during_router") + chunks = [chunk async for chunk in agent.respond(conversation, turn, "Status?")] + expected = ( + "Checking.\n\nUse OS2 on panel 1." + if route == "respond_directly" + else "Use OS2 on panel 1." + ) + assert "".join(chunks) == expected + assert turn.delivered_task_ids == {task.task_id} + assert task.status is TaskStatus.COMPLETED + + state = SessionState() + state.try_begin_start() + state.activate() + session = HistorySession(RecordingSession(), state, conversation, turn) + for chunk in chunks: + await session.send( + ResponseOutputTextDelta(response_id=turn.response_id, item_id="out", delta=chunk) + ) + assert conversation.history[-1].content == expected + await session.send(ResponseDone(response_id=turn.response_id)) + assert task.status is TaskStatus.DELIVERED + next_turn = Turn("r_after_delivery") + assert "".join( + [chunk async for chunk in agent.respond(conversation, next_turn, "Thanks")] + ) == "You're welcome." + assert not next_turn.delivered_task_ids + + +@pytest.mark.parametrize( + "ack", + ["", "Checking panel specifications and the latest site records."], + ids=["empty-ack", "chunked-ack"], +) +async def test_separators_only_between_nonempty_utterances( + monkeypatch: pytest.MonkeyPatch, + ack: str, +) -> None: + import agent_logic + + monkeypatch.setattr(agent_logic, "STATUS_INTERVAL", 0) + conversation = Conversation() + task = conversation.tasks.create_task("Find panels") + task.status = TaskStatus.RUNNING + status = "Checking the current fiber specifications and recent site updates." + result = "Use OS2 on panel 1, and verify the current fiber map before proceeding." + + async def finish(kwargs: dict) -> Any: + task.result = result + task.status = TaskStatus.COMPLETED + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Stale"))]) + + chat = Chat( + tool("respond_directly", message=ack), + SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=""))]), + SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=status))]), + finish, + ) + agent = ForegroundBackgroundAgent(chat, Responses(), "test") + chunks = [chunk async for chunk in agent.respond(conversation, Turn("r_segments"), "Status?")] + utterances = [text for text in (ack, status, result) if text] + expected_chunks = [] + for text in utterances: + if expected_chunks: + expected_chunks.append("\n\n") + expected_chunks.extend(text_chunks(text)) + assert chunks == expected_chunks + assert "".join(chunks) == "\n\n".join(utterances) + + +async def test_router_paraphrase_tracks_completed_task_by_id() -> None: + conversation = Conversation() + task = conversation.tasks.create_task("Specifications") + task.status = TaskStatus.COMPLETED + task.result = "Very long original English specification." + chat = Chat(tool("respond_directly", message="简短总结。", delivered_task_ids=[task.task_id])) + agent = ForegroundBackgroundAgent(chat, Responses(), "test") + turn = Turn("r_paraphrase") + assert ( + "".join([text async for text in agent.respond(conversation, turn, "Summarize")]) + == "简短总结。" + ) + assert turn.delivered_task_ids == {task.task_id} + assert task.status is TaskStatus.COMPLETED + + +def test_fresh_terminal_outcome_is_not_expired_by_old_creation_time() -> None: + import time + + conversation = Conversation() + task = conversation.tasks.create_task("Long-running work") + task.created_at = time.time() - 600 + task.status = TaskStatus.FAILED + task.finished_at = time.time() + conversation.tasks.cleanup() + assert conversation.tasks.get("latest") is task + + +def test_expired_latest_task_does_not_select_an_older_result() -> None: + import time + + conversation = Conversation() + older = conversation.tasks.create_task("Older result") + older.status = TaskStatus.COMPLETED + latest = conversation.tasks.create_task("Latest work") + latest.status = TaskStatus.FAILED + latest.finished_at = time.time() - 600 + conversation.tasks.cleanup() + assert conversation.tasks.get("latest") is None + + +def test_missing_history_is_not_appended_without_a_pending_first_send() -> None: + conversation = Conversation() + conversation.reconcile("r_pending_send", "Already heard") + assert not conversation.history + + +async def test_real_model_client_factories_can_close_without_network( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv( + "FOUNDRY_PROJECT_ENDPOINT", "https://example.services.ai.azure.com/api/projects/test" + ) + monkeypatch.setenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "test") + agent = ForegroundBackgroundAgent.from_environment() + await agent.close() + + +async def test_dedicated_smoke_client_uses_real_ce_websocket() -> None: + import websockets + from hypercorn.config import Config + from hypercorn.utils import wrap_app + + chat = Chat(tool("respond_directly", message="你好,field operations ready.")) + app = create_app( + ForegroundBackgroundAgent(chat, Responses(), "test"), configure_observability=None + ) + config = Config() + config.bind = ["127.0.0.1:0"] + config.accesslog = None + sockets = config.create_sockets() + port = sockets.insecure_sockets[0].getsockname()[1] + shutdown = asyncio.Event() + # Use Hypercorn's normal worker server on an OS-assigned port; no cloud fixture. + from hypercorn.asyncio.run import worker_serve + + server = asyncio.create_task( + worker_serve( + wrap_app(app, config.wsgi_max_body_size, None), + config, + sockets=sockets, + shutdown_trigger=shutdown.wait, + ) + ) + try: + for _ in range(100): + try: + async with websockets.connect(f"ws://127.0.0.1:{port}/voice_conversation_engine"): + break + except OSError: + await asyncio.sleep(0.01) + await run_smoke(f"ws://127.0.0.1:{port}/voice_conversation_engine", "Hello") + finally: + shutdown.set() + await asyncio.wait_for(server, 5) diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_delivery_history_bounds.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_delivery_history_bounds.py new file mode 100644 index 000000000..e172372ec --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_delivery_history_bounds.py @@ -0,0 +1,507 @@ +"""Delivery replay, all spoken history, and bounded connection-owned task data.""" + +from __future__ import annotations + +import asyncio +import time +from types import SimpleNamespace +from typing import Any + +import pytest +from azure.ai.agentserver.invocations.voice import ( + InputTextPart, + ResponseDone, + ResponseOutputTextDelta, + ResponseOutputTextDone, +) +from starlette.testclient import TestClient +from test_conversation_engine import ( + Chat, + RecordingSession, + Responses, + answer, + frame, + output, + send, + start, + tool, + until, +) + +from agent_logic import Conversation, ForegroundBackgroundAgent, Turn +from conversation_runtime import ConversationRuntime, HistorySession, create_app +from state import SessionState +from task_store import TaskStatus, TaskStore +from worker_agent import run_worker + + +@pytest.mark.parametrize( + "replay_text", + ["Use OS2 on panel 1.", "在一号面板使用 OS2。"], + ids=["repeat", "translate"], +) +def test_delivered_result_can_be_replayed_then_unknown_ids_are_rejected(replay_text: str) -> None: + async def replay(kwargs: dict) -> Any: + conversation = next(iter(app.state.voice_runtime.conversations.values())) + task = conversation.tasks.get("latest") + assert task.status is TaskStatus.DELIVERED + return tool("respond_directly", message=replay_text, delivered_task_ids=[task.task_id]) + + chat = Chat( + tool("start_task", request_summary="Find panels", ack_message="Checking."), + replay, + tool("respond_directly", message="Unknown result", delivered_task_ids=["unknown"]), + tool("respond_directly", message="You're welcome."), + ) + responses = Responses(answer("Use OS2 on panel 1.")) + app = create_app( + ForegroundBackgroundAgent(chat, responses, "test"), + configure_observability=None, + ) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + start(ws) + send(ws, "in_first", "Find panels") + assert output(until(ws)) == "Checking.\n\nUse OS2 on panel 1." + send(ws, "in_repeat", "Repeat or translate that result") + repeated = [] + while True: + item = ws.receive_json() + repeated.append(item) + if item["type"] in ("response.done", "error"): + break + assert repeated[-1]["type"] == "response.done" + assert output(repeated) == replay_text + assert repeated[0]["in_reply_to"] == ["in_repeat"] + conversation = next(iter(app.state.voice_runtime.conversations.values())) + assert conversation.tasks.get("latest").status is TaskStatus.DELIVERED + send(ws, "in_unknown", "An unknown result") + rejected = until(ws, "error") + assert not output(rejected) + assert not any(item["type"] == "response.done" for item in rejected) + send(ws, "in_thanks", "Thanks") + assert output(until(ws)) == "You're welcome." + assert len(responses.requests) == 1 + + +@pytest.mark.parametrize("status", [TaskStatus.COMPLETED, TaskStatus.DELIVERED]) +async def test_failed_result_send_never_rolls_back_or_advances_delivery(status: TaskStatus) -> None: + conversation = Conversation() + task = conversation.tasks.create_task("Find panels") + task.status, task.result = status, "Panel result" + chat = Chat(tool("respond_directly", message="Summary", delivered_task_ids=[task.task_id])) + turn = Turn("r_replay") + agent = ForegroundBackgroundAgent(chat, Responses(), "test") + assert ( + "".join([part async for part in agent.respond(conversation, turn, "Repeat")]) == "Summary" + ) + state = SessionState() + state.try_begin_start() + state.activate() + with pytest.raises(RuntimeError): + await HistorySession(RecordingSession(fail=True), state, conversation, turn).send( + ResponseDone(response_id=turn.response_id) + ) + assert task.status is status + await HistorySession(RecordingSession(), state, conversation, turn).send( + ResponseDone(response_id=turn.response_id) + ) + assert task.status is TaskStatus.DELIVERED + + +@pytest.mark.parametrize("count", [1, 2]) +@pytest.mark.parametrize("prior_turn", [False, True]) +def test_idle_reminder_is_recorded_once_and_duplicate_input_is_ignored( + count: int, + prior_turn: bool, +) -> None: + steps = [tool("respond_directly", message="Welcome.")] if prior_turn else [] + chat = Chat(*steps, tool("respond_directly", message="Thanks for confirming.")) + app = create_app( + ForegroundBackgroundAgent(chat, Responses(), "test"), + configure_observability=None, + ) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + start(ws) + if prior_turn: + send(ws, "in_prior", "Hello") + until(ws) + idle = frame("user.no_input", item_id="in_idle", count=count) + ws.send_json(idle) + reminder = until(ws) + assert not output(reminder) + assert [ + item["text"] for item in reminder if item["type"] == "response.output_text.done" + ] == ["Are you still there?"] + assert reminder[0]["in_reply_to"] == ["in_idle"] + conversation = next(iter(app.state.voice_runtime.conversations.values())) + assert sum(entry.content == "Are you still there?" for entry in conversation.history) == 1 + ws.send_json(idle) + send(ws, "in_yes", "Yes, I am still here.") + reply = until(ws) + assert reply[0]["in_reply_to"] == ["in_yes"] + assert output(reply) == "Thanks for confirming." + assert {"role": "assistant", "content": "Are you still there?"} in chat.requests[-1][ + "messages" + ] + + +@pytest.mark.parametrize("event_kind", ["user.barge_in", "response.cancelled"]) +def test_reminder_playback_correction_is_used_by_the_router(event_kind: str) -> None: + chat = Chat(tool("respond_directly", message="Thanks.")) + app = create_app( + ForegroundBackgroundAgent(chat, Responses(), "test"), + configure_observability=None, + ) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + start(ws) + ws.send_json(frame("user.no_input", item_id="in_idle", count=1)) + reminder = until(ws) + ws.send_json( + frame(event_kind, response_id=reminder[0]["response_id"], heard_text="Are you") + ) + send(ws, "in_yes", "Yes") + until(ws) + messages = chat.requests[0]["messages"] + assert {"role": "assistant", "content": "Are you"} in messages + assert "Are you still there?" not in repr(messages) + + +@pytest.mark.parametrize("streaming", [False, True]) +async def test_text_done_records_nonstreaming_text_without_duplicating_deltas( + streaming: bool, +) -> None: + conversation = Conversation() + state = SessionState() + state.try_begin_start() + state.activate() + session = HistorySession(RecordingSession(), state, conversation, Turn("r_text")) + if streaming: + for delta in ["Are you ", "still there?"]: + await session.send( + ResponseOutputTextDelta(response_id="r_text", item_id="out", delta=delta) + ) + await session.send( + ResponseOutputTextDone( + response_id="r_text", + item_id="out", + text="Are you still there?", + ) + ) + assert [(entry.response_id, entry.content) for entry in conversation.history] == [ + ("r_text", "Are you still there?") + ] + + +@pytest.mark.parametrize("callback", ["on_user_barge_in", "on_response_cancelled"]) +@pytest.mark.parametrize("heard_text", ["Are you", ""], ids=["heard-prefix", "unheard"]) +async def test_cancellation_during_first_nonstreaming_send_retains_heard_text( + callback: str, + heard_text: str, +) -> None: + entered, finished, gate = asyncio.Event(), asyncio.Event(), asyncio.Event() + sent = [] + + class GatedSession(RecordingSession): + async def send(self, message: Any) -> None: + if isinstance(message, ResponseOutputTextDone) and not entered.is_set(): + entered.set() + await gate.wait() + sent.append(message) + if isinstance(message, ResponseDone): + finished.set() + + chat = Chat(tool("respond_directly", message="Replacement")) + runtime = ConversationRuntime(ForegroundBackgroundAgent(chat, Responses(), "test")) + session = GatedSession() + state = runtime._store.get_or_create(session) + state.try_begin_start() + state.activate() + try: + await runtime.on_user_no_input(session, SimpleNamespace(item_id="in_idle", count=1)) + await asyncio.wait_for(entered.wait(), 1) + response_id = next(iter(state.responses)) + await getattr(runtime, callback)( + session, + SimpleNamespace(response_id=response_id, heard_text=heard_text), + ) + await runtime.on_user_message( + session, + SimpleNamespace(item_id="in_yes", content=[InputTextPart(text="Yes")]), + ) + await asyncio.wait_for(finished.wait(), 1) + assistants = [ + item["content"] for item in chat.requests[0]["messages"] if item["role"] == "assistant" + ] + assert assistants == ([heard_text] if heard_text else []) + assert "Are you still there?" not in repr(chat.requests[0]["messages"]) + assert not any( + isinstance(item, ResponseDone) and item.response_id == response_id for item in sent + ) + finally: + gate.set() + await runtime.close() + + +async def test_failed_nonstreaming_send_does_not_enter_history_and_next_turn_works() -> None: + failed, finished = asyncio.Event(), asyncio.Event() + + class FailOnce(RecordingSession): + async def send(self, message: Any) -> None: + if isinstance(message, ResponseOutputTextDone) and not failed.is_set(): + failed.set() + raise RuntimeError("Injected send failure") + if isinstance(message, ResponseDone): + finished.set() + + chat = Chat(tool("respond_directly", message="Next reply")) + runtime = ConversationRuntime(ForegroundBackgroundAgent(chat, Responses(), "test")) + session = FailOnce() + state = runtime._store.get_or_create(session) + state.try_begin_start() + state.activate() + try: + await runtime.on_user_no_input(session, SimpleNamespace(item_id="in_idle", count=1)) + await asyncio.wait_for(failed.wait(), 1) + await runtime.on_user_message( + session, + SimpleNamespace(item_id="in_next", content=[InputTextPart(text="Next")]), + ) + await asyncio.wait_for(finished.wait(), 1) + assert "Are you still there?" not in repr(chat.requests[0]["messages"]) + finally: + await runtime.close() + + +@pytest.mark.parametrize( + "status", + [ + TaskStatus.COMPLETED, + TaskStatus.DELIVERED, + TaskStatus.FAILED, + TaskStatus.CANCELLED, + ], +) +def test_all_terminal_results_expire_without_expiring_an_active_worker(status: TaskStatus) -> None: + store = TaskStore() + active = store.create_task("Active") + active.status, active.created_at = TaskStatus.RUNNING, time.time() - 86400 + expired = store.create_task("Expired") + expired.status, expired.finished_at = status, time.time() - 301 + store.cleanup() + assert store.get(expired.task_id) is None + assert store.get("latest") is None + assert store.get(active.task_id) is active + assert not active.cancel_event.is_set() + + +def test_capacity_evicts_oldest_terminal_result_but_never_marks_it_delivered() -> None: + store = TaskStore() + tasks = [] + now = time.time() + for index in range(16): + task = store.create_task(str(index)) + task.status, task.result, task.finished_at = ( + TaskStatus.COMPLETED, + "Result", + now - 16 + index, + ) + tasks.append(task) + new = store.create_task("New work") + assert store.get(tasks[0].task_id) is None + assert tasks[0].status is TaskStatus.COMPLETED + assert all(store.get(task.task_id) is task for task in tasks[1:]) + assert len(store.completed_tasks()) == 15 + assert store.get("latest") is new + + +def test_capacity_does_not_evict_active_tasks_and_failed_admission_can_retry() -> None: + store = TaskStore() + tasks = [store.create_task(str(index)) for index in range(16)] + with pytest.raises(ValueError): + store.create_task("Too many active tasks") + assert all(store.get(task.task_id) is task for task in tasks) + tasks[0].status, tasks[0].finished_at = TaskStatus.COMPLETED, time.time() + new = store.create_task("Retry") + assert store.get(tasks[0].task_id) is None + assert len(store.active_tasks()) == 16 + assert store.get("latest") is new + + +def test_oversized_query_rejection_does_not_evict_an_existing_result() -> None: + store = TaskStore() + tasks = [] + for index in range(16): + task = store.create_task(str(index)) + task.status, task.finished_at = TaskStatus.COMPLETED, time.time() + tasks.append(task) + with pytest.raises(ValueError): + store.create_task("Q" * 8001) + assert all(store.get(task.task_id) is task for task in tasks) + assert store.get("latest") is tasks[-1] + new = store.create_task("Q" * 8000) + assert store.get("latest") is new + + +@pytest.mark.parametrize("size", [8000, 8001], ids=["at-limit", "over-limit"]) +@pytest.mark.parametrize("cancelled", [False, True], ids=["normal", "cancelled-inflight"]) +async def test_worker_result_size_is_checked_on_every_final_answer( + size: int, cancelled: bool +) -> None: + loop = asyncio.get_running_loop() + task = TaskStore().create_task("Find panels") + + def model() -> Any: + if cancelled: + loop.call_soon_threadsafe(task.cancel_event.set) + return answer("界" * size) + + await run_worker(task, [], Responses(model), "test") + if size == 8000: + assert task.status is TaskStatus.COMPLETED + assert task.result == "界" * size + else: + assert task.status is TaskStatus.FAILED + assert len(task.result) <= 8000 + assert "界" not in task.result + assert task.finished_at is not None + + +async def test_task_context_is_bounded_and_full_retained_result_can_still_be_fetched() -> None: + conversation = Conversation() + for index in range(16): + task = conversation.tasks.create_task(f"Query {index}") + task.status, task.result, task.finished_at = TaskStatus.COMPLETED, "界" * 8000, time.time() + latest = conversation.tasks.get("latest") + chat = Chat(tool("get_task_result", task_id="latest")) + agent = ForegroundBackgroundAgent(chat, Responses(), "test") + turn = Turn("r_full_result") + assert ( + "".join([part async for part in agent.respond(conversation, turn, "Read latest")]) + == latest.result + ) + contexts = [ + item["content"] for item in chat.requests[0]["messages"][1:] if item["role"] == "system" + ] + assert len(contexts) == 1 and len(contexts[0]) <= 8000 + assert "get_task_result" in contexts[0] + assert "界" not in contexts[0] # Omit result bodies instead of presenting partial answers. + assert turn.delivered_task_ids == {latest.task_id} + assert latest.status is TaskStatus.COMPLETED + + +@pytest.mark.parametrize("evicted", [False, True], ids=["expired", "evicted"]) +async def test_unavailable_result_is_explicit_and_never_redirects_to_an_older_task( + evicted: bool, +) -> None: + conversation = Conversation() + target = conversation.tasks.create_task("Target") + target.status, target.result, target.finished_at = ( + TaskStatus.COMPLETED, + "Old result", + time.time() - 301, + ) + if evicted: + target.finished_at = time.time() - 20 + for index in range(16): + task = conversation.tasks.create_task(str(index)) + task.status, task.result, task.finished_at = ( + TaskStatus.COMPLETED, + "Other result", + time.time(), + ) + chat = Chat(tool("get_task_result", task_id=target.task_id)) + agent = ForegroundBackgroundAgent(chat, Responses(), "test") + turn = Turn("r_unavailable") + text = "".join([part async for part in agent.respond(conversation, turn, "Read target")]) + assert "expired" in text.lower() and "evicted" in text.lower() + assert "Old result" not in text and "Other result" not in text + assert not turn.delivered_task_ids + + +def test_terminal_retention_boundary_is_measured_from_completion( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import task_store + + store = TaskStore() + tasks = [store.create_task(str(index)) for index in range(3)] + for task, finished_at in zip(tasks, [700.001, 700.0, 699.999], strict=True): + task.status, task.created_at, task.finished_at = TaskStatus.COMPLETED, 0, finished_at + monkeypatch.setattr(task_store.time, "time", lambda: 1000.0) + store.cleanup() + assert store.get(tasks[0].task_id) is tasks[0] + assert store.get(tasks[1].task_id) is tasks[1] + assert store.get(tasks[2].task_id) is None + assert store.get("latest") is None + + +async def test_small_task_context_keeps_complete_results() -> None: + conversation = Conversation() + task = conversation.tasks.create_task("Find panels") + task.status, task.result = TaskStatus.COMPLETED, "Use OS2 on panel 1." + chat = Chat(tool("respond_directly", message=task.result, delivered_task_ids=[task.task_id])) + agent = ForegroundBackgroundAgent(chat, Responses(), "test") + turn = Turn("r_small_context") + assert ( + "".join([part async for part in agent.respond(conversation, turn, "Read result")]) + == task.result + ) + assert task.result in repr(chat.requests[0]["messages"]) + assert "Result bodies omitted" not in repr(chat.requests[0]["messages"]) + assert turn.delivered_task_ids == {task.task_id} + + +@pytest.mark.parametrize("mixed_claim", [False, True], ids=["unseen-only", "visible-and-unseen"]) +async def test_unseen_result_claim_is_rejected_atomically_then_can_be_retrieved( + mixed_claim: bool, +) -> None: + conversation = Conversation() + visible = conversation.tasks.create_task("Earlier work") + visible.status, visible.result = TaskStatus.COMPLETED, "Earlier result" + task = conversation.tasks.create_task("Find panels") + task.status = TaskStatus.RUNNING + + async def finish_and_report(kwargs: dict) -> Any: + request = repr(kwargs["messages"]) + assert visible.result in request and "Panel result" not in request + await asyncio.sleep(0) + task.status, task.result, task.finished_at = ( + TaskStatus.COMPLETED, + "Panel result", + time.time(), + ) + reported = [visible.task_id, task.task_id] if mixed_claim else [task.task_id] + return tool("respond_directly", message="Still checking.", delivered_task_ids=reported) + + agent = ForegroundBackgroundAgent( + Chat(finish_and_report, tool("get_task_result", task_id=task.task_id)), Responses(), "test" + ) + turn = Turn("r_became_ready") + with pytest.raises(ValueError): + await anext(agent.respond(conversation, turn, "Status?")) + assert not turn.delivered_task_ids + assert task.status is visible.status is TaskStatus.COMPLETED + assert all(entry.role == "user" for entry in conversation.history) + retry = Turn("r_retrieved") + assert ( + "".join([part async for part in agent.respond(conversation, retry, "Read the result")]) + == task.result + ) + assert retry.delivered_task_ids == {task.task_id} + assert task.status is TaskStatus.COMPLETED + state = SessionState() + state.try_begin_start() + state.activate() + session = HistorySession(RecordingSession(), state, conversation, retry) + await session.send( + ResponseOutputTextDelta( + response_id=retry.response_id, + item_id="out_result", + delta=task.result, + ) + ) + assert task.status is TaskStatus.COMPLETED + await session.send(ResponseDone(response_id=retry.response_id)) + assert conversation.history[-1].content == task.result + assert task.status is TaskStatus.DELIVERED + assert visible.status is TaskStatus.COMPLETED diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_heard_text_budget.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_heard_text_budget.py new file mode 100644 index 000000000..2925d0a23 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_heard_text_budget.py @@ -0,0 +1,79 @@ +"""Heard prefixes use the response byte budget, not the user-input limit.""" + +import pytest + +from response_coordinator import MAX_OUTPUT_BYTES +from state import MAX_MESSAGE_CHARACTERS, MAX_TRACKED_PLAYBACK_RESPONSES, SessionState + + +@pytest.mark.parametrize( + "unit,count,tail,expected_count", + [ + ("x", 7999, "", 7999), + ("x", 8000, "", 8000), + ("x", 8001, "", 8001), + ("x", 8011, "", 8011), + ("👋", 8001, "", 8001), + ("x", 524288, "", 524288), + ("x", 524289, "", 524288), + ("👋", 131072, "", 131072), + ("👋", 131073, "", 131072), + ("界", 174763, "", 174762), + ("x", 524287, "👋", 524287), + ], + ids=[ + "below-input-limit", + "at-input-limit", + "above-input-limit", + "result-plus-ack", + "long-unicode", + "exact-byte-budget", + "over-byte-budget", + "unicode-exact-budget", + "unicode-over-budget", + "three-byte-boundary", + "partial-rune-must-not-be-retained", + ], +) +def test_heard_prefix_is_complete_within_output_budget_and_bounded_above_it( + unit: str, + count: int, + tail: str, + expected_count: int, +) -> None: + state = SessionState() + state.track_response("r_known") + heard = unit * count + tail + state.record_heard_text("r_known", heard) + stored = state.heard_text_by_response["r_known"] + assert len(stored) == len(unit * expected_count) + assert stored == unit * expected_count + assert heard.startswith(stored) + assert len(stored.encode("utf-8")) <= 512 * 1024 + if len(heard.encode("utf-8")) <= 512 * 1024: + assert stored == heard + state.record_heard_text("r_known", "") + assert state.heard_text_by_response["r_known"] == "" + state.record_heard_text("r_known", "retry prefix") + assert state.heard_text_by_response == {"r_known": "retry prefix"} + + +def test_unknown_and_evicted_responses_cannot_repopulate_playback_cache() -> None: + state = SessionState() + state.record_heard_text("r_unknown", "x" * 8011) + assert not state.heard_text_by_response + for index in range(MAX_TRACKED_PLAYBACK_RESPONSES + 1): + state.track_response(f"r_{index}") + state.record_heard_text("r_0", "evicted prefix") + assert "r_0" not in state.heard_text_by_response + assert len(state.heard_text_by_response) == 64 + state.record_heard_text("r_64", "x" * 8011) + assert len(state.heard_text_by_response["r_64"]) == 8011 + assert sum(len(text.encode("utf-8")) for text in state.heard_text_by_response.values()) <= ( + 64 * 512 * 1024 + ) + + +def test_response_budget_does_not_expand_user_input_or_output_limits() -> None: + assert MAX_MESSAGE_CHARACTERS == 8000 + assert MAX_OUTPUT_BYTES == 512 * 1024 diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_heard_text_history.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_heard_text_history.py new file mode 100644 index 000000000..3142190fb --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_heard_text_history.py @@ -0,0 +1,195 @@ +"""Playback correction preserves actual heard output and subsequent model context.""" + +import asyncio +from types import SimpleNamespace + +import pytest +from azure.ai.agentserver.invocations.voice import ( + InputTextPart, + ResponseDone, + ResponseOutputTextDone, +) +from starlette.testclient import TestClient +from test_conversation_engine import ( + Chat, + RecordingSession, + Responses, + answer, + frame, + output, + send, + start, + tool, + until, + wait_for_server, +) + +from agent_logic import Conversation, ForegroundBackgroundAgent, HistoryEntry, Turn +from conversation_runtime import ConversationRuntime, HistorySession, create_app +from task_store import TaskStatus + + +@pytest.mark.parametrize("event_kind", ["user.barge_in", "response.cancelled"]) +@pytest.mark.parametrize( + "heard_size", [0, 8000, 8011], ids=["unheard", "input-boundary", "full-reply"] +) +@pytest.mark.parametrize("unit", ["x", "界"], ids=["ascii", "unicode"]) +def test_real_sdk_retains_complete_heard_worker_reply_then_accepts_next_turn( + event_kind: str, + heard_size: int, + unit: str, +) -> None: + marker = "FINAL_HEARD_STEP" + result = unit * (8000 - len(marker)) + marker + chat = Chat( + tool("start_task", request_summary="Find panels", ack_message="Checking."), + tool("respond_directly", message="Next."), + tool("respond_directly", message="Finished."), + ) + agent = ForegroundBackgroundAgent(chat, Responses(answer(result)), "test") + app = create_app(agent, configure_observability=None) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + start(ws) + send(ws, "in_work", "Find panels") + frames = until(ws) + spoken = output(frames) + assert spoken == "Checking.\n\n" + result and len(spoken) == 8011 + response_id = frames[0]["response_id"] + runtime = app.state.voice_runtime + session = next(iter(runtime.conversations)) + conversation = runtime.conversations[session] + state = runtime._state_for(session) + task = conversation.tasks.get("latest") + wait_for_server(client, lambda: task.status is TaskStatus.DELIVERED) + heard = spoken[:heard_size] + ws.send_json(frame(event_kind, response_id=response_id, heard_text=heard)) + # A larger playback budget must not relax ordinary user-input admission. + send(ws, "in_oversized", "x" * 8001) + declined = until(ws, "response.none") + assert declined[-1]["reason"] == "input_too_large" + assert len(chat.requests) == 1 + send(ws, "in_next", "Continue") + replacement = until(ws) + assert output(replacement) == "Next." + assert replacement[0]["in_reply_to"] == ["in_next"] + assert all(f.get("response_id") != response_id for f in replacement) + assert state.heard_text_by_response[response_id] == heard + assert ( + next(e.content for e in conversation.history if e.response_id == response_id) == heard + ) + assistants = [ + m["content"] for m in chat.requests[1]["messages"] if m["role"] == "assistant" + ] + assert assistants == ([heard] if heard else []) + if heard_size == 8011: + assert marker in assistants[0] + assert task.status is TaskStatus.DELIVERED + # A repeated correction can clear the entry; it must not resurrect old output. + ws.send_json(frame(event_kind, response_id=response_id, heard_text="")) + send(ws, "in_later", "Continue again") + assert output(until(ws)) == "Finished." + assert state.heard_text_by_response[response_id] == "" + assert spoken not in repr(chat.requests[2]["messages"]) + ws.send_json(frame("session.end", reason="test_complete")) + wait_for_server( + client, lambda: not state.heard_text_by_response and not runtime.conversations + ) + assert not agent.workers + assert not app.state.voice_runtime._coordinator._detached_tasks + + +@pytest.mark.parametrize("callback", ["on_user_barge_in", "on_response_cancelled"]) +async def test_long_heard_prefix_during_first_send_survives_cancellation(callback: str) -> None: + entered, finished, gate = asyncio.Event(), asyncio.Event(), asyncio.Event() + heard = "界" * 8011 + sent = [] + + class GatedSession(RecordingSession): + async def send(self, message): + if isinstance(message, ResponseOutputTextDone) and not entered.is_set(): + entered.set() + await gate.wait() + sent.append(message) + if isinstance(message, ResponseDone): + finished.set() + + agent = ForegroundBackgroundAgent( + Chat(tool("respond_directly", message="Replacement")), Responses(), "test" + ) + runtime = ConversationRuntime(agent) + session = GatedSession() + state = runtime._store.get_or_create(session) + state.try_begin_start() + state.activate() + conversation = Conversation() + runtime.conversations[session] = conversation + turn = Turn("r_pending") + try: + runtime._coordinator.start_response( + HistorySession(session, state, conversation, turn), + state, + "in_original", + text=heard, + streaming=False, + response_id=turn.response_id, + ) + await asyncio.wait_for(entered.wait(), 1) + await getattr(runtime, callback)( + session, + SimpleNamespace(response_id=turn.response_id, heard_text=heard), + ) + await runtime.on_user_message( + session, + SimpleNamespace( + item_id="in_replacement", + content=[InputTextPart(text="Continue")], + ), + ) + await asyncio.wait_for(finished.wait(), 1) + assistants = [ + m["content"] for m in agent.chat.requests[0]["messages"] if m["role"] == "assistant" + ] + assert assistants == [heard] + assert state.heard_text_by_response[turn.response_id] == heard + assert sum(e.response_id == turn.response_id for e in conversation.history) == 1 + assert not any( + isinstance(m, ResponseDone) and m.response_id == turn.response_id for m in sent + ) + finally: + gate.set() + await runtime.close() + assert not state.heard_text_by_response and not agent.workers + + +async def test_oversized_heard_text_uses_same_bounded_value_for_history_and_playback() -> None: + agent = ForegroundBackgroundAgent(Chat(), Responses(), "test") + runtime = ConversationRuntime(agent) + session = RecordingSession() + state = runtime._store.get_or_create(session) + state.try_begin_start() + state.activate() + state.track_response("r_known") + conversation = Conversation() + conversation.history.append(HistoryEntry("assistant", "sent prefix", "r_known")) + runtime.conversations[session] = conversation + prefix = "x" * (512 * 1024 - 1) + try: + await runtime.on_user_barge_in( + session, + SimpleNamespace( + response_id="r_known", + heard_text=prefix + "👋", + ), + ) + assert len(conversation.history[0].content) == len(prefix) + assert conversation.history[0].content == state.heard_text_by_response["r_known"] == prefix + await runtime.on_response_cancelled( + session, + SimpleNamespace( + response_id="r_unknown", + heard_text="must not be inserted", + ), + ) + assert len(conversation.history) == 1 and len(state.heard_text_by_response) == 1 + finally: + await runtime.close() diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_model_delivery_contracts.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_model_delivery_contracts.py new file mode 100644 index 000000000..f8057f834 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_model_delivery_contracts.py @@ -0,0 +1,330 @@ +"""Model input/output contracts and delivery's post-send commit boundary.""" + +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any + +import httpx +import pytest +from azure.ai.agentserver.invocations.voice import ResponseDone +from openai import OpenAI +from starlette.testclient import TestClient +from test_conversation_engine import ( + Chat, + RecordingSession, + Responses, + answer, + output, + send, + start, + tool, + until, + wait_for_server, +) + +import worker_agent +from agent_logic import Conversation, ForegroundBackgroundAgent, HistoryEntry, Turn, task_message +from conversation_runtime import HistorySession, create_app +from state import SessionState +from task_store import TaskStatus + + +@pytest.mark.parametrize( + "message", ["", " \n\t", None, 7], ids=["empty", "space", "null", "number"] +) +def test_invalid_delivery_claim_cannot_be_committed_by_another_result(message: Any) -> None: + async def finish_other_task(kwargs: dict) -> Any: + second.status, second.result, second.finished_at = ( + TaskStatus.COMPLETED, + "RESULT B ONLY", + time.time(), + ) + return tool("respond_directly", message=message, delivered_task_ids=[first.task_id]) + + async def retry(kwargs: dict) -> Any: + return tool("respond_directly", message=first.result, delivered_task_ids=[first.task_id]) + + chat = Chat(tool("respond_directly", message="Hello"), finish_other_task, retry) + app = create_app( + ForegroundBackgroundAgent(chat, Responses(), "test"), configure_observability=None + ) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + start(ws) + send(ws, "in_setup", "Hello") + until(ws) + conversation = next(iter(app.state.voice_runtime.conversations.values())) + + def seed() -> tuple: + a = conversation.tasks.create_task("First") + a.status, a.result = TaskStatus.COMPLETED, "RESULT A NEVER SPOKEN" + b = conversation.tasks.create_task("Second") + b.status = TaskStatus.RUNNING + return a, b + + first, second = client.portal.call(seed) + send(ws, "in_bad", "Read first") + frames = [] + while not frames or frames[-1]["type"] not in ("error", "response.done"): + frames.append(ws.receive_json()) + assert frames[-1]["type"] == "error" + assert not output(frames) + assert first.status is second.status is TaskStatus.COMPLETED + assert all(entry.content != second.result for entry in conversation.history) + send(ws, "in_retry", "Read first again") + assert output(until(ws)) == first.result + wait_for_server(client, lambda: first.status is TaskStatus.DELIVERED) + assert second.status is TaskStatus.COMPLETED + + +async def test_empty_unrelated_reply_can_still_deliver_a_newly_completed_result() -> None: + conversation = Conversation() + task = conversation.tasks.create_task("Second") + task.status = TaskStatus.RUNNING + + async def finish(kwargs: dict) -> Any: + task.status, task.result = TaskStatus.COMPLETED, "RESULT B ONLY" + return tool("respond_directly", message="", delivered_task_ids=[]) + + agent = ForegroundBackgroundAgent(Chat(finish), Responses(), "test") + turn = Turn("r_empty_ack") + assert "".join([s async for s in agent.respond(conversation, turn, "Status")]) == task.result + assert turn.delivered_task_ids == {task.task_id} + + +@pytest.mark.parametrize("oversized_entry", [False, True], ids=["many-entries", "large-entry"]) +async def test_router_and_new_worker_share_bounded_recent_history(oversized_entry: bool) -> None: + conversation = Conversation() + # Legal earlier CE replies, collectively much larger than the model-history budget. + for index in range(18): + conversation.history.append(HistoryEntry("assistant", str(index) + "x" * 7999)) + if oversized_entry: + conversation.history.append(HistoryEntry("assistant", "y" * 160_000)) + conversation.history.append(HistoryEntry("user", "Recent question")) + conversation.history.append(HistoryEntry("assistant", "Unheard answer", "r_heard")) + conversation.reconcile("r_heard", "HEARD ONLY") + before = [(e.role, e.content) for e in conversation.history] + chat = Chat(tool("start_task", request_summary="Find panels", ack_message="Checking.")) + responses = Responses(answer("Panel result")) + agent = ForegroundBackgroundAgent(chat, responses, "test") + try: + assert "Panel result" in "".join( + [s async for s in agent.respond(conversation, Turn("r_budget"), "Find panels")] + ) + model_history = chat.requests[0]["messages"][1:-1] + assert sum(len(m["content"]) for m in model_history) <= 16_000 + worker_history = responses.requests[0]["input"][:-1] + assert [{"role": m["role"], "content": m["content"]} for m in worker_history] == ( + model_history + ) + assert model_history[-2:] == [ + {"role": "user", "content": "Recent question"}, + {"role": "assistant", "content": "HEARD ONLY"}, + ] + assert "Unheard answer" not in repr(model_history) + # Preparing a bounded snapshot must not clip or overwrite retained history. + assert [(e.role, e.content) for e in conversation.history][:-1] == before[-19:] + finally: + await agent.close() + + +@pytest.mark.parametrize("size", [16_000, 16_001], ids=["exact-budget", "over-budget"]) +async def test_model_history_budget_keeps_whole_entries_and_current_input(size: int) -> None: + conversation = Conversation() + conversation.history.append(HistoryEntry("assistant", "界" * size)) + chat = Chat(tool("respond_directly", message="Hello")) + agent = ForegroundBackgroundAgent(chat, Responses(), "test") + assert "".join([s async for s in agent.respond(conversation, Turn("r_size"), "Now")]) == "Hello" + messages = chat.requests[0]["messages"] + assert messages[-1] == {"role": "user", "content": "Now"} + history = messages[1:-1] + assert history == ([{"role": "assistant", "content": "界" * size}] if size == 16_000 else []) + assert conversation.history[0].content == "界" * size + + +@pytest.mark.parametrize("large", [False, True], ids=["full-context", "explicit-retrieval"]) +async def test_recall_after_history_eviction_keeps_full_result_or_requires_lookup( + large: bool, +) -> None: + conversation = Conversation() + task = conversation.tasks.create_task("Procedure") + task.status = TaskStatus.DELIVERED + task.result = "Initial steps. " + "x" * (7960 if large else 400) + " FINAL STEP" + conversation.history.append(HistoryEntry("assistant", task.result, "r_original")) + for index in range(20): + conversation.history.append(HistoryEntry("user", f"Other turn {index}")) + assert all(e.response_id != "r_original" for e in conversation.history) + + async def recall(kwargs: dict) -> Any: + context = "\n".join(m["content"] for m in kwargs["messages"] if m["role"] == "system") + if large: + assert "Result bodies omitted" in context and "get_task_result" in context + assert "Initial steps" not in context + return tool("get_task_result", task_id=task.task_id) + assert task.result in context + return tool( + "respond_directly", message="Translated FINAL STEP", delivered_task_ids=[task.task_id] + ) + + agent = ForegroundBackgroundAgent(Chat(recall), Responses(), "test") + turn = Turn("r_recall") + spoken = "".join([s async for s in agent.respond(conversation, turn, "Repeat or translate")]) + assert spoken == (task.result if large else "Translated FINAL STEP") + assert task.status is TaskStatus.DELIVERED + + +@pytest.mark.parametrize("fail", [False, True], ids=["send-returns", "send-fails"]) +async def test_delivery_waits_for_server_send_return_not_client_receipt(fail: bool) -> None: + received, release = asyncio.Event(), asyncio.Event() + + class Transport: + async def send(self, message: Any) -> None: + received.set() # The client can observe the terminal before send returns. + await release.wait() + if fail: + raise RuntimeError("transport failed") + + conversation = Conversation() + task = conversation.tasks.create_task("Panels") + task.status, task.result = TaskStatus.COMPLETED, "Result" + state = SessionState() + state.try_begin_start() + state.activate() + turn = Turn("r_commit", {task.task_id}) + sending = asyncio.create_task( + HistorySession(Transport(), state, conversation, turn).send( + ResponseDone(response_id=turn.response_id) + ) + ) + try: + await asyncio.wait_for(received.wait(), 1) + assert task.status is TaskStatus.COMPLETED + finally: + release.set() + result = await asyncio.wait_for(asyncio.gather(sending, return_exceptions=True), 1) + assert isinstance(result[0], RuntimeError) is fail + assert task.status is (TaskStatus.COMPLETED if fail else TaskStatus.DELIVERED) + # Successful retry is idempotent and only commits after its own successful send. + await HistorySession(RecordingSession(), state, conversation, turn).send( + ResponseDone(response_id=turn.response_id) + ) + assert task.status is TaskStatus.DELIVERED + + +@pytest.mark.parametrize("kind", ["tools", "fifth-answer", "refusal", "empty", "space", "text"]) +@pytest.mark.parametrize("cancelled", [False, True], ids=["normal", "cancelled-inflight"]) +async def test_real_sdk_worker_final_outcomes_are_truthful( + monkeypatch: pytest.MonkeyPatch, + kind: str, + cancelled: bool, +) -> None: + requests, tools = [], [] + conversation = Conversation() + task = conversation.tasks.create_task("Find panels") + expected_calls = 5 if kind in ("tools", "fifth-answer") else 1 + if cancelled: + expected_calls = 1 + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(json.loads(request.content)) + if cancelled: + task.cancel_event.set() + if kind == "tools" or (kind == "fifth-answer" and len(requests) < 5): + items = [ + { + "type": "function_call", + "id": f"fc_{len(requests)}", + "call_id": f"call_{len(requests)}", + "name": "search_site_specs", + "arguments": "{}", + "status": "completed", + } + ] + else: + content = ( + [{"type": "refusal", "refusal": "I cannot assist with this request."}] + if kind == "refusal" + else [ + { + "type": "output_text", + "text": {"empty": "", "space": " \n"}.get(kind, "Panel result"), + "annotations": [], + } + ] + ) + items = [ + { + "type": "message", + "id": "msg_final", + "role": "assistant", + "status": "completed", + "content": content, + } + ] + return httpx.Response( + 200, + json={ + "id": f"resp_{len(requests)}", + "object": "response", + "created_at": 0, + "model": "test", + "status": "completed", + "output": items, + "tools": [], + "tool_choice": "auto", + "parallel_tool_calls": False, + }, + ) + + def execute(name: str, arguments: dict) -> str: + tools.append(name) + return "{}" + + monkeypatch.setattr(worker_agent, "_execute_tool", execute) + with OpenAI( + api_key="offline-test", + base_url="https://offline.invalid/v1", + http_client=httpx.Client(transport=httpx.MockTransport(handle)), + ) as client: + await worker_agent.run_worker(task, [], client.responses, "test") + success = kind in ("text", "fifth-answer") and not (cancelled and kind == "fifth-answer") + assert task.status is ( + TaskStatus.COMPLETED + if success + else TaskStatus.CANCELLED + if cancelled + else TaskStatus.FAILED + ) + assert len(requests) == expected_calls + assert len(tools) == ( + 0 if cancelled else 5 if kind == "tools" else 4 if kind == "fifth-answer" else 0 + ) + assert task.finished_at is not None + assert "(No response)" not in task.result + assert "Reached maximum" not in task_message(task) + agent = ForegroundBackgroundAgent( + Chat(tool("get_task_result", task_id=task.task_id)), Responses(), "test" + ) + turn = Turn("r_status") + spoken = "".join([s async for s in agent.respond(conversation, turn, "Status")]) + assert spoken == task_message(task) + assert turn.delivered_task_ids == ({task.task_id} if success else set()) + state = SessionState() + state.try_begin_start() + state.activate() + await HistorySession(RecordingSession(), state, conversation, turn).send( + ResponseDone(response_id=turn.response_id) + ) + assert task.status is ( + TaskStatus.DELIVERED + if success + else TaskStatus.CANCELLED + if cancelled + else TaskStatus.FAILED + ) + # A failed task does not poison later independent work on the same connection. + retry = conversation.tasks.create_task("Try a new request") + await worker_agent.run_worker(retry, [], Responses(answer("New result")), "test") + assert retry.status is TaskStatus.COMPLETED and retry.result == "New result" diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_review_regressions.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_review_regressions.py new file mode 100644 index 000000000..ce5e93898 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_review_regressions.py @@ -0,0 +1,529 @@ +"""Router truthfulness, Worker continuation/cancellation, and bounded history.""" + +from __future__ import annotations + +import asyncio +import json +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace +from typing import Any + +import httpx +import pytest +from azure.ai.agentserver.invocations.voice import ( + InputTextPart, + ResponseDone, + ResponseOutputTextDelta, +) +from openai import OpenAI +from starlette.testclient import TestClient +from test_conversation_engine import ( + Chat, + RecordingSession, + Responses, + answer, + frame, + output, + send, + start, + tool, + until, +) + +import worker_agent +from agent_logic import Conversation, ForegroundBackgroundAgent, Turn, task_message +from conversation_runtime import ConversationRuntime, HistorySession, create_app +from router_agent import ROUTER_TOOLS +from state import SessionState +from task_store import TaskStatus + + +async def test_busy_request_is_declined_and_can_be_retried() -> None: + conversation = Conversation() + old = conversation.tasks.create_task("Find panels") + old.status = TaskStatus.RUNNING + chat = Chat( + *[ + tool("start_task", request_summary="Analyze document 12345", ack_message="Analyzing.") + for _ in range(2) + ] + ) + responses = Responses(answer("Document result")) + agent = ForegroundBackgroundAgent(chat, responses, "test") + turn = Turn("r_busy") + stream = agent.respond(conversation, turn, "Analyze document 12345") + try: + acknowledgement = await anext(stream) + assert "Analyzing" not in acknowledgement + assert "haven't started" in acknowledgement + assert not agent.workers + assert conversation.tasks.get("latest") is old + old.result = "Panel result" + old.status = TaskStatus.COMPLETED + assert "Panel result" in "".join([part async for part in stream]) + assert turn.delivered_task_ids == {old.task_id} + state = SessionState() + state.try_begin_start() + state.activate() + await HistorySession(RecordingSession(), state, conversation, turn).send( + ResponseDone(response_id=turn.response_id) + ) + assert old.status is TaskStatus.DELIVERED + retry = Turn("r_retry") + result = "".join([part async for part in agent.respond(conversation, retry, "Try again")]) + assert result == "Analyzing.\n\nDocument result" + new = conversation.tasks.get("latest") + assert new is not old and new.query == "Analyze document 12345" + assert retry.delivered_task_ids == {new.task_id} + assert len(responses.requests) == 1 + finally: + await stream.aclose() + await agent.close() + + +@pytest.mark.parametrize("route", ["check_task_status", "get_task_result"]) +@pytest.mark.parametrize("expired", [False, True], ids=["absent", "expired"]) +async def test_missing_task_is_not_reported_as_running(route: str, expired: bool) -> None: + conversation = Conversation() + if expired: + task = conversation.tasks.create_task("Old work") + task.status = TaskStatus.FAILED + task.finished_at = time.time() - 600 + chat = Chat( + tool(route, task_id="latest", ack_message="Still working on that."), + tool("respond_directly", message="Please provide a new request."), + ) + agent = ForegroundBackgroundAgent(chat, Responses(), "test") + text = "".join( + [part async for part in agent.respond(conversation, Turn("r_missing"), "Status?")] + ) + assert text.startswith("No task found") + assert "expired" in text and "evicted" in text + assert conversation.tasks.get("latest") is None + assert not agent.workers + assert ( + "".join([part async for part in agent.respond(conversation, Turn("r_next"), "What next?")]) + == "Please provide a new request." + ) + + +@pytest.mark.parametrize("status", [TaskStatus.FAILED, TaskStatus.CANCELLED]) +@pytest.mark.parametrize("route", ["respond_directly", "check_task_status", "get_task_result"]) +@pytest.mark.parametrize("during", ["router", "ack"]) +async def test_terminal_worker_outcome_is_reported_once( + status: TaskStatus, + route: str, + during: str, +) -> None: + conversation = Conversation() + task = conversation.tasks.create_task("Find panels") + task.status = TaskStatus.RUNNING + task.result = "PRIVATE_DIAGNOSTIC" + + async def route_request(kwargs: dict) -> Any: + await asyncio.sleep(0) + if during == "router": + task.status = status + return tool(route, task_id=task.task_id, message="Checking.", ack_message="Checking.") + + agent = ForegroundBackgroundAgent(Chat(route_request), Responses(), "test") + turn = Turn("r_terminal") + stream = agent.respond(conversation, turn, "Status?") + first = await anext(stream) + if during == "ack": + assert first == "Checking." + task.status = status + result = first + "".join([part async for part in stream]) + terminal = task_message(task) + assert result.count(terminal) == 1 + assert "PRIVATE" not in result + assert not turn.delivered_task_ids + assert task.status is status + assert not agent.workers + + +@pytest.mark.parametrize( + "reported", + [None, "task-id", [123], ["unknown"], {}], + ids=["null", "string", "non-string-id", "unknown-id", "object"], +) +async def test_invalid_delivery_ids_fail_before_output_and_allow_retry(reported: Any) -> None: + conversation = Conversation() + task = conversation.tasks.create_task("Find panels") + task.status = TaskStatus.COMPLETED + task.result = "Detailed result" + chat = Chat( + tool("respond_directly", message="Summary.", delivered_task_ids=reported), + tool("respond_directly", message="Summary.", delivered_task_ids=[task.task_id]), + ) + agent = ForegroundBackgroundAgent(chat, Responses(), "test") + failed_turn = Turn("r_invalid") + with pytest.raises(ValueError): + await anext(agent.respond(conversation, failed_turn, "Summarize")) + assert not failed_turn.delivered_task_ids + assert task.status is TaskStatus.COMPLETED + retry = Turn("r_valid") + assert ( + "".join([part async for part in agent.respond(conversation, retry, "Try again")]) + == "Summary." + ) + assert retry.delivered_task_ids == {task.task_id} + state = SessionState() + state.try_begin_start() + state.activate() + with pytest.raises(RuntimeError): + await HistorySession(RecordingSession(fail=True), state, conversation, retry).send( + ResponseDone(response_id=retry.response_id) + ) + assert task.status is TaskStatus.COMPLETED + await HistorySession(RecordingSession(), state, conversation, retry).send( + ResponseDone(response_id=retry.response_id) + ) + assert task.status is TaskStatus.DELIVERED + + +def test_missing_delivery_ids_are_a_protocol_error_not_untracked_success() -> None: + definition = next(item for item in ROUTER_TOOLS if item["name"] == "respond_directly") + assert "delivered_task_ids" in definition["parameters"]["required"] + malformed = tool("respond_directly", message="Untracked summary.") + malformed.choices[0].message.tool_calls[0].function.arguments = json.dumps( + {"message": "Untracked summary."} + ) + chat = Chat(malformed, tool("respond_directly", message="Next reply.")) + app = create_app( + ForegroundBackgroundAgent(chat, Responses(), "test"), + configure_observability=None, + ) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + start(ws) + send(ws, "in_missing_ids", "Summarize") + frames = until(ws, "error") + assert not output(frames) + assert not any(item["type"] == "response.done" for item in frames) + send(ws, "in_after_error", "Next") + assert output(until(ws)) == "Next reply." + + +@pytest.mark.parametrize("kind", ["user.barge_in", "response.cancelled"]) +def test_evicted_playback_is_not_appended_as_latest_history(kind: str) -> None: + chat = Chat(*[tool("respond_directly", message=f"Answer {index}") for index in range(12)]) + app = create_app( + ForegroundBackgroundAgent(chat, Responses(), "test"), + configure_observability=None, + ) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + start(ws) + for index in range(11): + send(ws, f"in_{index}", f"Question {index}") + frames = until(ws) + if index == 0: + old_response = frames[0]["response_id"] + conversation = next(iter(app.state.voice_runtime.conversations.values())) + assert len(conversation.history) == 20 + assert all(entry.response_id != old_response for entry in conversation.history) + ws.send_json(frame(kind, response_id=old_response, heard_text="OLD HEARD PREFIX")) + send(ws, "in_after_playback", "Next question") + assert output(until(ws)) == "Answer 11" + assert "OLD HEARD PREFIX" not in repr(chat.requests[-1]["messages"]) + assert chat.requests[-1]["messages"][-2] == {"role": "assistant", "content": "Answer 10"} + + +@pytest.mark.parametrize("callback", ["on_user_barge_in", "on_response_cancelled"]) +async def test_first_inflight_send_retains_heard_text(callback: str) -> None: + entered = asyncio.Event() + finished = asyncio.Event() + gate = asyncio.Event() + sent = [] + + class GatedSession(RecordingSession): + async def send(self, message: Any) -> None: + if isinstance(message, ResponseOutputTextDelta) and not entered.is_set(): + entered.set() + await gate.wait() + sent.append(message) + if isinstance(message, ResponseDone): + finished.set() + + chat = Chat( + tool("respond_directly", message="Already heard. Unheard suffix."), + tool("respond_directly", message="Next reply."), + ) + runtime = ConversationRuntime(ForegroundBackgroundAgent(chat, Responses(), "test")) + session = GatedSession() + state = runtime._store.get_or_create(session) + state.try_begin_start() + state.activate() + try: + await runtime.on_user_message( + session, + SimpleNamespace(item_id="in_first", content=[InputTextPart(text="Question")]), + ) + await asyncio.wait_for(entered.wait(), 1) + response_id = next(iter(state.responses)) + history_session = state.responses[response_id].session + assert history_session.first_text_pending + await getattr(runtime, callback)( + session, + SimpleNamespace(response_id=response_id, heard_text="Already heard."), + ) + conversation = runtime.conversations[session] + assert conversation.history[-1].content == "Already heard." + assert conversation.history[-1].response_id == response_id + await runtime.on_user_message( + session, + SimpleNamespace(item_id="in_next", content=[InputTextPart(text="Next")]), + ) + await asyncio.wait_for(finished.wait(), 1) + assert not history_session.first_text_pending + assert {"role": "assistant", "content": "Already heard."} in chat.requests[-1]["messages"] + assert "Unheard suffix" not in repr(chat.requests[-1]["messages"]) + finally: + gate.set() + await runtime.close() + + +async def test_only_first_delta_can_authorize_missing_history_insertion() -> None: + flags = [] + + class InspectSession(RecordingSession): + async def send(self, message: Any) -> None: + flags.append(session.first_text_pending) + + state = SessionState() + state.try_begin_start() + state.activate() + session = HistorySession(InspectSession(), state, Conversation(), Turn("r_flags")) + for text in ["First part", "Later part"]: + await session.send( + ResponseOutputTextDelta( + response_id="r_flags", + item_id="out", + delta=text, + ) + ) + assert not session.first_text_pending + assert flags == [True, False] + + +@pytest.mark.parametrize("cancelled", [False, True], ids=["execute", "cancel-before-entry"]) +@pytest.mark.parametrize("stage", ["model", "tool"]) +async def test_cancelled_executor_jobs_do_not_start( + monkeypatch: pytest.MonkeyPatch, + stage: str, + cancelled: bool, +) -> None: + loop = asyncio.get_running_loop() + queued = asyncio.Event() + release = threading.Event() + task = Conversation().tasks.create_task("Find panels") + calls = [] + + class ObservedPool(ThreadPoolExecutor): + submissions = 0 + + def submit(self, fn, /, *args, **kwargs): + job = super().submit(fn, *args, **kwargs) + self.submissions += 1 + if self.submissions == (2 if stage == "model" else 3): + loop.call_soon_threadsafe(queued.set) + return job + + pool = ObservedPool(max_workers=1) + loop.set_default_executor(pool) + + def occupy_thread() -> None: + assert release.wait(5) + + def execute(name: str, arguments: dict) -> str: + calls.append(name) + return "{}" + + def model() -> Any: + if stage == "tool": + pool.submit(occupy_thread) + call = SimpleNamespace( + type="function_call", + id="fc_1", + call_id="call_1", + name="search_site_specs", + arguments="{}", + ) + return answer("", output=[call]) + + monkeypatch.setattr(worker_agent, "_execute_tool", execute) + responses = Responses(model, answer("Panel result")) + if stage == "model": + pool.submit(occupy_thread) + worker = asyncio.create_task(worker_agent.run_worker(task, [], responses, "test")) + try: + await asyncio.wait_for(queued.wait(), 2) + if cancelled: + task.cancel_event.set() + release.set() + await asyncio.wait_for(asyncio.shield(worker), 2) + if cancelled: + assert not calls + assert len(responses.requests) == (0 if stage == "model" else 1) + assert task.status is TaskStatus.CANCELLED + else: + assert calls == ["search_site_specs"] + assert len(responses.requests) == 2 + assert task.status is TaskStatus.COMPLETED + assert task.finished_at is not None + finally: + release.set() + await worker + pool.shutdown(wait=True) + + +async def test_cancellation_drains_started_tool_without_starting_the_next( + monkeypatch: pytest.MonkeyPatch, +) -> None: + loop = asyncio.get_running_loop() + entered = asyncio.Event() + release = threading.Event() + calls = [] + + def execute(name: str, arguments: dict) -> str: + calls.append(name) + loop.call_soon_threadsafe(entered.set) + assert release.wait(5) + return "{}" + + monkeypatch.setattr(worker_agent, "_execute_tool", execute) + tool_calls = [ + SimpleNamespace( + type="function_call", + id=f"fc_{index}", + call_id=f"call_{index}", + name=name, + arguments="{}", + ) + for index, name in enumerate(["search_site_specs", "analyze_document"]) + ] + responses = Responses(answer("", output=tool_calls)) + task = Conversation().tasks.create_task("Find panels") + worker = asyncio.create_task(worker_agent.run_worker(task, [], responses, "test")) + try: + await asyncio.wait_for(entered.wait(), 2) + task.cancel_event.set() + await asyncio.sleep(0) + assert not worker.done() + release.set() + await asyncio.wait_for(asyncio.shield(worker), 2) + assert calls == ["search_site_specs"] + assert len(responses.requests) == 1 + assert task.status is TaskStatus.CANCELLED + assert task.finished_at is not None + finally: + release.set() + await worker + + +@pytest.mark.parametrize("reasoning", [False, True], ids=["plain", "reasoning"]) +@pytest.mark.parametrize("tool_failure", [False, True], ids=["tool-success", "tool-failure"]) +async def test_worker_replays_all_output_items_in_order( + monkeypatch: pytest.MonkeyPatch, + reasoning: bool, + tool_failure: bool, +) -> None: + requests = [] + returned = [] + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(json.loads(request.content)) + index = len(requests) + items = [] + if index < 3: + if reasoning: + items.append( + { + "id": f"rs_{index}", + "type": "reasoning", + "summary": [], + "encrypted_content": f"opaque_{index}", + } + ) + items.append( + { + "id": f"msg_{index}", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + {"type": "output_text", "text": "Checking sources.", "annotations": []} + ], + } + ) + for suffix in ["a", "b"] if index == 1 else ["c"]: + items.append( + { + "id": f"fc_{suffix}", + "type": "function_call", + "call_id": f"call_{suffix}", + "name": "search_site_specs", + "arguments": "{}", + "status": "completed", + } + ) + else: + items.append( + { + "id": "msg_final", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "Panel result", "annotations": []}], + } + ) + returned.append(items) + return httpx.Response( + 200, + json={ + "id": f"resp_{index}", + "object": "response", + "created_at": 0, + "model": "test", + "status": "completed", + "output": items, + "parallel_tool_calls": False, + "tool_choice": "auto", + "tools": [], + }, + ) + + def execute(name: str, arguments: dict) -> str: + if tool_failure: + raise RuntimeError("PRIVATE_TOOL_DIAGNOSTIC") + return "{}" + + monkeypatch.setattr(worker_agent, "_execute_tool", execute) + task = Conversation().tasks.create_task("Find panels") + with OpenAI( + api_key="offline-test", + base_url="https://offline.invalid/v1", + http_client=httpx.Client(transport=httpx.MockTransport(handle)), + ) as client: + await worker_agent.run_worker( + task, + [{"type": "message", "role": "user", "content": "Find panels"}], + client.responses, + "test", + ) + assert task.status is TaskStatus.COMPLETED + assert task.result == "Panel result" + assert len(requests) == 3 + for index in range(2): + expected_prefix = requests[index]["input"] + returned[index] + replay = requests[index + 1]["input"] + assert replay[: len(expected_prefix)] == expected_prefix + results = replay[len(expected_prefix) :] + assert [item["call_id"] for item in results] == [ + item["call_id"] for item in returned[index] if item["type"] == "function_call" + ] + assert all(item["type"] == "function_call_output" for item in results) + assert "PRIVATE_TOOL_DIAGNOSTIC" not in repr(results) + assert all(request["store"] is False for request in requests) + assert all(request["include"] == ["reasoning.encrypted_content"] for request in requests) diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_router_delivery_visibility.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_router_delivery_visibility.py new file mode 100644 index 000000000..ef648237b --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_router_delivery_visibility.py @@ -0,0 +1,293 @@ +"""Delivery authority comes from complete results in the actual Router request. + +Model doubles may inspect the request, but task-store access is only for arranging +Worker completion, never evidence that the Router saw the newly available answer. +""" + +from __future__ import annotations + +import asyncio +import threading +from typing import Any + +import pytest +from azure.ai.agentserver.invocations.voice import ResponseDone, ResponseOutputTextDelta +from starlette.testclient import TestClient +from test_conversation_engine import ( + Chat, + RecordingSession, + Responses, + answer, + frame, + output, + send, + start, + tool, + until, + wait_for_server, +) + +from agent_logic import Conversation, ForegroundBackgroundAgent, Turn +from conversation_runtime import HistorySession, create_app +from state import SessionState +from task_store import TaskStatus + + +@pytest.mark.parametrize("status", [TaskStatus.COMPLETED, TaskStatus.DELIVERED]) +@pytest.mark.parametrize("over_budget", [False, True], ids=["exact-budget", "index-only"]) +async def test_claims_follow_complete_context_not_task_store( + status: TaskStatus, + over_budget: bool, +) -> None: + conversation = Conversation() + task = conversation.tasks.create_task("Procedure") + task.status = status + prefix = ( + f'Completed task {task.task_id} "{task.query}": ' + if status is TaskStatus.COMPLETED + else f"DELIVERED: task {task.task_id} — '{task.query}' → " + ) + # Change one character: the whole 8,000-char context fits, or bodies are omitted. + task.result = "Full procedure: " + "x" * (8000 + over_budget - len(prefix) - 16) + claimed_text = "简要翻译。" + + async def route(kwargs: dict) -> Any: + context = [m["content"] for m in kwargs["messages"][1:] if m["role"] == "system"] + assert len(context) == 1 and len(context[0]) <= 8000 + assert (task.result in context[0]) is not over_budget + assert ("Result bodies omitted" in context[0]) is over_budget + return tool("respond_directly", message=claimed_text, delivered_task_ids=[task.task_id]) + + chat = Chat(route, tool("get_task_result", task_id=task.task_id)) + agent = ForegroundBackgroundAgent(chat, Responses(), "test") + turn = Turn("r_context") + if over_budget: + with pytest.raises(ValueError): + await anext(agent.respond(conversation, turn, "Summarize")) + assert not turn.delivered_task_ids + assert all(entry.role == "user" for entry in conversation.history) + else: + assert "".join([s async for s in agent.respond(conversation, turn, "Summarize")]) == ( + claimed_text + ) + assert turn.delivered_task_ids == {task.task_id} + assert task.status is status + # An explicit lookup supplies the complete answer even when model context is index-only. + retry = Turn("r_lookup") + chunks = [s async for s in agent.respond(conversation, retry, "Read the full result")] + assert "".join(chunks) == task.result + assert retry.delivered_task_ids == ({task.task_id} if status is TaskStatus.COMPLETED else set()) + state = SessionState() + state.try_begin_start() + state.activate() + session = HistorySession(RecordingSession(), state, conversation, retry) + for chunk in chunks: + await session.send( + ResponseOutputTextDelta( + response_id=retry.response_id, + item_id="out_result", + delta=chunk, + ) + ) + assert task.status is status + with pytest.raises(RuntimeError): + await HistorySession(RecordingSession(fail=True), state, conversation, retry).send( + ResponseDone(response_id=retry.response_id) + ) + assert task.status is status + await session.send(ResponseDone(response_id=retry.response_id)) + assert task.status is TaskStatus.DELIVERED + assert conversation.history[-1].content == task.result + await session.send(ResponseDone(response_id=retry.response_id)) + assert task.status is TaskStatus.DELIVERED # Replays do not reopen or duplicate delivery. + + +@pytest.mark.parametrize("route", ["respond_directly", "get_task_result", "check_task_status"]) +def test_real_worker_finishing_after_router_snapshot_cannot_be_falsely_delivered( + route: str, +) -> None: + entered, release = threading.Event(), threading.Event() + result = "Use OS2 on panel 1." + + def blocked_model() -> Any: + entered.set() + assert release.wait(5) + return answer(result) + + async def finish_during_routing(kwargs: dict) -> Any: + conversation = next(iter(app.state.voice_runtime.conversations.values())) + task = conversation.tasks.get("latest") + assert result not in repr(kwargs["messages"]) + assert task.status is TaskStatus.RUNNING + release.set() + await asyncio.wait_for(asyncio.gather(*tuple(agent.workers)), 2) + assert task.status is TaskStatus.COMPLETED + if route == "respond_directly": + return tool( + route, + message="Still checking the panel details.", + delivered_task_ids=[task.task_id], + ) + return tool(route, task_id=task.task_id, ack_message="Still checking.") + + chat = Chat( + tool("start_task", request_summary="Find panels", ack_message="Looking."), + finish_during_routing, + tool("get_task_result", task_id="latest"), + tool("respond_directly", message="You're welcome."), + ) + agent = ForegroundBackgroundAgent(chat, Responses(blocked_model), "test") + app = create_app(agent, configure_observability=None) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + try: + start(ws) + send(ws, "in_work", "Find panels") + first = ws.receive_json() + assert ws.receive_json()["delta"] == "Looking." + assert entered.wait(2) + ws.send_json( + frame("user.barge_in", response_id=first["response_id"], heard_text="Looking.") + ) + send(ws, "in_status", "Status?") + frames = [] + while not frames or frames[-1]["type"] not in ("error", "response.done"): + frames.append(ws.receive_json()) + conversation = next(iter(app.state.voice_runtime.conversations.values())) + task = conversation.tasks.get("latest") + if route == "respond_directly": + assert frames[-1]["type"] == "error" + assert frames[-1]["code"] == "generation_failed" + assert not output(frames) + assert not any(f["type"] == "response.done" for f in frames) + assert sum(f["type"] == "error" for f in frames) == 1 + assert task.status is TaskStatus.COMPLETED + assert all(e.content != result for e in conversation.history) + else: + assert frames[-1]["type"] == "response.done" + assert output(frames) == result + wait_for_server(client, lambda: task.status is TaskStatus.DELIVERED) + send(ws, "in_retrieve", "Read the result") + retry = until(ws) + assert output(retry) == result + assert retry[0]["in_reply_to"] == ["in_retrieve"] + wait_for_server(client, lambda: task.status is TaskStatus.DELIVERED) + send(ws, "in_later", "Thanks") + later = until(ws) + assert output(later) == "You're welcome." + assert later[0]["in_reply_to"] == ["in_later"] + assert all(f.get("response_id") != first["response_id"] for f in frames + retry + later) + assert all(f.get("response_id") != frames[0]["response_id"] for f in retry + later) + assert not any(f["type"] == "error" for f in retry + later) + assert len(agent.responses.requests) == 1 + finally: + release.set() + assert not agent.workers + assert not app.state.voice_runtime.conversations + + +def test_index_only_result_claim_fails_before_speech_then_sdk_lookup_delivers() -> None: + async def claim_index_only(kwargs: dict) -> Any: + assert task.result not in repr(kwargs["messages"]) + assert "Result bodies omitted" in repr(kwargs["messages"]) + return tool( + "respond_directly", message="Still checking.", delivered_task_ids=[task.task_id] + ) + + agent = ForegroundBackgroundAgent( + Chat( + tool("respond_directly", message="Hello."), + claim_index_only, + tool("get_task_result", task_id="latest"), + tool("respond_directly", message="Welcome."), + ), + Responses(), + "test", + ) + app = create_app(agent, configure_observability=None) + with TestClient(app) as client, client.websocket_connect("/voice_conversation_engine") as ws: + start(ws) + send(ws, "in_setup", "Hello") + until(ws) + conversation = next(iter(app.state.voice_runtime.conversations.values())) + + def seed() -> Any: + task = conversation.tasks.create_task("Procedure") + task.status, task.result = TaskStatus.COMPLETED, "x" * 8000 + return task + + task = client.portal.call(seed) + send(ws, "in_index", "Read the procedure") + frames = [] + while not frames or frames[-1]["type"] not in ("error", "response.done"): + frames.append(ws.receive_json()) + assert frames[-1]["type"] == "error" + assert frames[-1]["code"] == "generation_failed" + assert not output(frames) + assert not any(f["type"] == "response.done" for f in frames) + assert task.status is TaskStatus.COMPLETED + assert all(e.content != "Still checking." for e in conversation.history) + send(ws, "in_lookup", "Read the full result") + retrieved = until(ws) + assert retrieved[0]["in_reply_to"] == ["in_lookup"] + assert output(retrieved) == task.result + assert all(f.get("response_id") != frames[0]["response_id"] for f in retrieved) + assert not any(f["type"] == "error" for f in retrieved) + wait_for_server(client, lambda: task.status is TaskStatus.DELIVERED) + send(ws, "in_after_lookup", "Thanks") + assert output(until(ws)) == "Welcome." + assert not app.state.voice_runtime.conversations + assert not agent.workers + + +async def test_cancelled_router_does_not_authorize_delivery_and_next_turn_can_retry() -> None: + entered, release = asyncio.Event(), asyncio.Event() + conversation = Conversation() + task = conversation.tasks.create_task("Find panels") + task.status, task.result = TaskStatus.COMPLETED, "Panel result" + + async def held_router(kwargs: dict) -> Any: + assert task.result in repr(kwargs["messages"]) + entered.set() + await release.wait() + return tool("respond_directly", message="Panel summary", delivered_task_ids=[task.task_id]) + + agent = ForegroundBackgroundAgent( + Chat(held_router, tool("get_task_result", task_id=task.task_id)), Responses(), "test" + ) + turn = Turn("r_cancelled") + stream = agent.respond(conversation, turn, "Summarize") + pending = asyncio.create_task(anext(stream)) + try: + await asyncio.wait_for(entered.wait(), 1) + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + finally: + release.set() + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + await stream.aclose() + assert not turn.delivered_task_ids + assert task.status is TaskStatus.COMPLETED + assert all(e.role == "user" for e in conversation.history) + retry = Turn("r_after_cancel") + assert ( + "".join([s async for s in agent.respond(conversation, retry, "Read result")]) == task.result + ) + assert retry.delivered_task_ids == {task.task_id} + assert task.status is TaskStatus.COMPLETED + state = SessionState() + state.try_begin_start() + state.activate() + session = HistorySession(RecordingSession(), state, conversation, retry) + await session.send( + ResponseOutputTextDelta( + response_id=retry.response_id, + item_id="out_result", + delta=task.result, + ) + ) + assert task.status is TaskStatus.COMPLETED + await session.send(ResponseDone(response_id=retry.response_id)) + assert task.status is TaskStatus.DELIVERED + assert conversation.history[-1].content == task.result diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_shutdown_checkpoint.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_shutdown_checkpoint.py new file mode 100644 index 000000000..b79a71b1a --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/tests/test_shutdown_checkpoint.py @@ -0,0 +1,17 @@ +"""Drain must progress when all retained tasks finished before the join.""" + +import asyncio + +import pytest + +from response_coordinator import ResponseCoordinator + + +@pytest.mark.asyncio +async def test_drain_yields_to_completed_task_release_callback() -> None: + coordinator = ResponseCoordinator() + task = asyncio.create_task(asyncio.sleep(0)) + await task + coordinator._retain_task(task) + await asyncio.wait_for(coordinator.wait_for_detached_tasks(), 1) + assert not coordinator._detached_tasks diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/uv.lock b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/uv.lock new file mode 100644 index 000000000..31fa8cdbc --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/uv.lock @@ -0,0 +1,2055 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[options] +prerelease-mode = "allow" + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-08-11T15:32:50Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-24T00:24:14Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-30T16:41:25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-24T08:35:40Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-08-07T07:53:11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-23T19:14:04Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-07-30T17:35:59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-07-23T23:02:58Z" }, +] + +[[package]] +name = "azure-ai-agentserver-core" +version = "2.3.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "azure-core" }, + { name = "azure-identity" }, + { name = "hypercorn" }, + { name = "isodate" }, + { name = "microsoft-opentelemetry" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/f9/5a28099787dba9140bbf2d31324171d23b6a4ed7cdd0a63af6c0ce34ed8f/azure_ai_agentserver_core-2.3.0b2.tar.gz", hash = "sha256:2cb04f717de58946b7ba08afee3d3c5c196c16b88dca32056ec941b4195784c9", size = 431302, upload-time = "2026-10-27T07:28:56.375Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/4f/d813b962a92614853ddcde709db6f5d558a75e2e7de9d6846668b5a986ea/azure_ai_agentserver_core-2.3.0b2-py3-none-any.whl", hash = "sha256:e28592d98b8b18914d4065c64cbb8bbcc080b45af52aa48ad1f6cb2ce836021d", size = 224586, upload-time = "2026-10-27T07:28:58.055Z" }, +] + +[[package]] +name = "azure-ai-agentserver-invocations" +version = "1.3.0b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "azure-ai-agentserver-core" }, + { name = "azure-core" }, + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/f3/47da7e95ef260bf998e0c7fb029e4afd13c497819524704e7911289ca688/azure_ai_agentserver_invocations-1.3.0b1.tar.gz", hash = "sha256:ec31c444aff3e3b54e05a58020dff5b5c5609e8e1c9c662a02dcc32d5c40ee2b", size = 225164, upload-time = "2026-10-27T12:15:20.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/2e/46cfc0da972107fa955bbf25c5e74c899683216c25c013b72790e7e5f550/azure_ai_agentserver_invocations-1.3.0b1-py3-none-any.whl", hash = "sha256:86355f6cd10f5cf3949e9df3cf8c6bdfac5a2193aca3a2c28156b5e5ff09d295", size = 62558, upload-time = "2026-10-27T12:15:22.076Z" }, +] + +[[package]] +name = "azure-ai-projects" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "azure-identity" }, + { name = "azure-storage-blob" }, + { name = "isodate" }, + { name = "openai" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f9/a15c8a16e35e6d620faebabc6cc4f9e2f4b7f1d962cc6f58931c46947e24/azure_ai_projects-2.0.1.tar.gz", hash = "sha256:c8c64870aa6b89903af69a4ff28b4eff3df9744f14615ea572cae87394946a0c", size = 491774, upload-time = "2026-03-12T19:59:02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/f7/290ca39501c06c6e23b46ba9f7f3dfb05ecc928cde105fed85d6845060dd/azure_ai_projects-2.0.1-py3-none-any.whl", hash = "sha256:dfda540d256e67a52bf81c75418b6bf92b811b96693fe45787e154a888ad2396", size = 236560, upload-time = "2026-07-24T16:03:44Z" }, +] + +[[package]] +name = "azure-core" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-08-04T05:16:26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-07-23T18:44:42Z" }, +] + +[[package]] +name = "azure-core-tracing-opentelemetry" +version = "1.0.0b13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/ab/a937e4af8afec9d437d55252f2a3a4419fc3fc7d5e5d54022622bd11b2b6/azure_core_tracing_opentelemetry-1.0.0b13.tar.gz", hash = "sha256:6cb2f8dfd5dee6c11843db0205fc92e2434e1a272c169c953afe92483aafc7eb", size = 25832, upload-time = "2026-08-07T22:23:36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/01/8898c2506cae6a57c1b76d930d2af94764a65354bc863feb2684235851ce/azure_core_tracing_opentelemetry-1.0.0b13-py3-none-any.whl", hash = "sha256:4dacd3a9f117f11f98e89305e161c951b8df85b984f3b56130614de9cd9887f9", size = 12112, upload-time = "2026-07-23T18:44:51Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-07-31T17:22:56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-07-21T10:19:44Z" }, +] + +[[package]] +name = "azure-monitor-opentelemetry-exporter" +version = "1.0.0b59" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "azure-identity" }, + { name = "msrest" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/f1/14baca95c4b8a0fc4fb4e57644e9c37d6f2ed08f6d3e5f344750a2209b89/azure_monitor_opentelemetry_exporter-1.0.0b59.tar.gz", hash = "sha256:0dfaec6b1aada54a4c8f4572fd1e1da67609459caf418298b5d91b4c67c0282e", size = 369603, upload-time = "2026-11-05T20:51:56.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/d2/946debf3b9e536813e62420c3888e5b371341b07d9c6daf7965442b26855/azure_monitor_opentelemetry_exporter-1.0.0b59-py2.py3-none-any.whl", hash = "sha256:e831b74132fb899e640cddbb340f0fbfe11937f788715ce3f0acd120c8e91227", size = 259551, upload-time = "2026-11-05T20:51:58.42Z" }, +] + +[[package]] +name = "azure-storage-blob" +version = "12.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/48/84a820d898267f662b5c06f7cd76fdb8a9e272b44aa9376cef3ec0f6a294/azure_storage_blob-12.30.0.tar.gz", hash = "sha256:2cd74d4d5731e5eb6b8d5c5056ee115a5e88f8fdf22517b739836fda685018be", size = 618229, upload-time = "2026-08-07T09:30:38Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/0b/e106f0fd7fa785867d9ffcc47dc9e6237c0e58f51058473b777487a98edc/azure_storage_blob-12.30.0-py3-none-any.whl", hash = "sha256:d415ac50b67a8da6b3ae7e9f1014b1b55cd7aafa0b8d4ca9b380568dc7360423", size = 435610, upload-time = "2026-07-24T03:03:52Z" }, +] + +[[package]] +name = "certifi" +version = "2026.10.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2026-07-30T17:39:15Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2026-07-24T00:45:05Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-08-01T02:32:56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-08-01T02:46:05Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-08-04T05:51:26Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-08-01T05:19:27Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-08-04T05:50:12Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-08-04T05:49:42Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-08-04T01:12:20Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-08-04T05:51:35Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-08-01T05:12:28Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-08-04T05:50:42Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-08-01T02:27:51Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-08-11T23:10:54Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-08-02T23:15:54Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-08-01T02:23:36Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-08-04T05:49:51Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-08-04T05:51:17Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-08-04T05:51:31Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-08-18T14:34:42Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-08-04T05:50:28Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-08-04T05:50:46Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-08-04T05:50:55Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-08-18T14:35:15Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-08-11T16:35:08Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-08-04T05:50:01Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-08-04T05:50:23Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-08-04T05:50:19Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-08-04T05:51:39Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-08-02T00:22:47Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-08-13T20:19:28Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-08-06T15:54:17Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-08-04T05:51:03Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-08-04T05:51:08Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-08-04T05:51:13Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-08-04T05:51:43Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-08-04T05:49:47Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-08-04T05:51:48Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-08-03T13:11:47Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-08-13T20:19:29Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-08-04T05:51:22Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-08-01T06:29:57Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2026-07-30T17:39:30Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2026-07-23T19:04:46Z" }, +] + +[[package]] +name = "foreground-background-agents-voicelive" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "azure-ai-agentserver-core" }, + { name = "azure-ai-agentserver-invocations" }, + { name = "azure-ai-projects" }, + { name = "azure-identity" }, + { name = "httpx" }, + { name = "openai" }, + { name = "opentelemetry-api" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx2" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "azure-ai-agentserver-core", specifier = "==2.3.0b2" }, + { name = "azure-ai-agentserver-invocations", specifier = "==1.3.0b1" }, + { name = "azure-ai-projects", specifier = "==2.0.1" }, + { name = "azure-identity", specifier = "==1.25.3" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "openai", specifier = "==2.54.0" }, + { name = "opentelemetry-api", specifier = "==1.45.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx2", specifier = "==2.12.0" }, + { name = "pytest", specifier = "==9.1.1" }, + { name = "pytest-asyncio", specifier = "==1.4.0" }, + { name = "ruff", specifier = "==0.16.5" }, + { name = "websockets", specifier = "==17.1" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/74bcab964c9a7a61f2bb71e8179b0f13e6fa98f7ce00fd168aab291e4a2e/googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071", size = 150967, upload-time = "2026-08-28T09:16:33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/51/186c02b8549b69ccda44429cf6ff5081e4b61a602ddfe6a8020d1be31d1b/googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79", size = 300626, upload-time = "2026-08-09T07:44:50Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2026-08-06T07:02:18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2026-07-23T18:49:18Z" }, +] + +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-15T11:25:20Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-04T00:04:49Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-08-31T21:56:56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-07-24T13:43:26Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2026-07-30T16:19:41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2026-07-23T19:03:29Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2026-07-30T16:23:39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2026-07-23T18:58:27Z" }, +] + +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "hypercorn" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2026-08-07T07:58:03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2026-07-24T03:04:26Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2026-07-31T22:54:19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2026-07-24T15:58:37Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-07-30T16:14:46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-07-21T10:21:07Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2026-07-31T21:48:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2026-07-23T18:51:40Z" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, +] + +[[package]] +name = "microsoft-opentelemetry" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "azure-core" }, + { name = "azure-core-tracing-opentelemetry" }, + { name = "azure-monitor-opentelemetry-exporter" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-django" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-flask" }, + { name = "opentelemetry-instrumentation-httpx" }, + { name = "opentelemetry-instrumentation-logging" }, + { name = "opentelemetry-instrumentation-openai-agents-v2" }, + { name = "opentelemetry-instrumentation-openai-v2" }, + { name = "opentelemetry-instrumentation-psycopg2" }, + { name = "opentelemetry-instrumentation-requests" }, + { name = "opentelemetry-instrumentation-urllib" }, + { name = "opentelemetry-instrumentation-urllib3" }, + { name = "opentelemetry-resource-detector-azure" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-util-genai" }, + { name = "pyjwt" }, + { name = "requests" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/bb/152888858a33bd6cee7a07e2cfd61284ccc92af98233b269f5240546cafb/microsoft_opentelemetry-1.6.0.tar.gz", hash = "sha256:77b82f9d75d357e31bf5a71f6a1bb006a84c81a7f6c3c509eae274d953456a5a", size = 225845, upload-time = "2026-11-06T01:29:33.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/62/985c40e9094352a5cc1b81c41b70bd3aaa0bf16d17cb0ba90b0045eacfa0/microsoft_opentelemetry-1.6.0-py3-none-any.whl", hash = "sha256:6c328cff0330ca4909c790900abd2bd80a62a6a840b83388864592a4c796beae", size = 245303, upload-time = "2026-11-06T01:29:34.731Z" }, +] + +[[package]] +name = "msal" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-07-31T17:22:41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-07-23T18:48:01Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2026-08-06T06:31:43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2026-07-23T18:54:07Z" }, +] + +[[package]] +name = "msrest" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "certifi" }, + { name = "isodate" }, + { name = "requests" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/77/8397c8fb8fc257d8ea0fa66f8068e073278c65f05acb17dcb22a02bfdc42/msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9", size = 175332, upload-time = "2026-07-31T21:48:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/cf/f2966a2638144491f8696c27320d5219f48a072715075d168b31d3237720/msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32", size = 85384, upload-time = "2026-07-23T18:50:54Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2026-08-08T00:08:48Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2026-07-23T18:51:48Z" }, +] + +[[package]] +name = "openai" +version = "2.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/9a/8c75e8c8a5b407a0586faeb2afac91674ff955c191ecc1d6d3b6669f6788/openai-2.54.0.tar.gz", hash = "sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa", size = 1100285, upload-time = "2026-09-09T05:05:42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/a8/bb76c7356de8ad57f59d5ff993d434df0607f07f08bcc9c9a5c275e399c0/openai-2.54.0-py3-none-any.whl", hash = "sha256:89089789197ccdb87f173a03145ed1598d00795220c93e96cf712b1cbf5e5f2b", size = 1660351, upload-time = "2026-08-12T16:34:36Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "opentelemetry-exporter-http-transport" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/0c/e3ebdb4b507f66afcc905e6885a4946969bd75b45988492643356fbbdc63/opentelemetry_exporter_http_transport-0.66b1.tar.gz", hash = "sha256:443080203bf52586ce0b2ad901e8951c61833eab1aa539ae6f1f16fe9e8e7952", size = 11693, upload-time = "2026-10-06T17:32:59.65Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/69/6af86ff66492b481c6a4c05dcfd68beb47ed8ba046440a26a2aac76b95c7/opentelemetry_exporter_http_transport-0.66b1-py3-none-any.whl", hash = "sha256:2f95404bdee7f9d2d529c7de56c7bd86d014d774d8fbf137810e0167f8a492bf", size = 12155, upload-time = "2026-10-06T17:32:35.454Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-common" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/19/41de712173f43057e4532d42ece7d0c6d4210d353e5752433cb14987643f/opentelemetry_exporter_otlp_common-0.66b1.tar.gz", hash = "sha256:6b1403487a2185ac1feb45fd5546fdf8630ce71c36bcefaadf51e2130e9e23f9", size = 14325, upload-time = "2026-10-06T17:33:01.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/39/8c23d67665c762aa51840fa06f86e902e8f6f1693bc8d7e3d98cd6e2f753/opentelemetry_exporter_otlp_common-0.66b1-py3-none-any.whl", hash = "sha256:00ff8592c3a7cb729ff3fdc7ffa12372c243bdf2163e80c180994d0c7bd83ee9", size = 12385, upload-time = "2026-10-06T17:32:38.177Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/8e/65e85e5137991a3c493b11682151d198638a5bc1dd4b4c5f67e013c57d7c/opentelemetry_exporter_otlp_proto_common-1.45.1.tar.gz", hash = "sha256:2e4adcc3a67bcf57804fc49514f0ef64974ca7590aa3491da389852b4a0628f6", size = 18873, upload-time = "2026-10-06T17:33:04.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/aa/92f225d353904e7f70b8b3e3c1b02db0cf56f744c2e83c581dc372e78873/opentelemetry_exporter_otlp_proto_common-1.45.1-py3-none-any.whl", hash = "sha256:2f446183ae7047b036226f1d846c41a834b0e8755ad13b51a51dd38952eb466c", size = 15393, upload-time = "2026-10-06T17:32:41.911Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-http-transport", extra = ["requests"] }, + { name = "opentelemetry-exporter-otlp-common" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/17/26487707ea4caa97b17e6e4b5fa72133a53512ffa2f5cf7a49ef284b29cb/opentelemetry_exporter_otlp_proto_http-1.45.1.tar.gz", hash = "sha256:45c218405ce3fd879596924b1874bf9a8f6880206d61065c5a912c8e5c297fb7", size = 28839, upload-time = "2026-10-06T17:33:05.713Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/1f/517eaa0187ba106a9da97160ce2add3a371812681dc440930b267f714e42/opentelemetry_exporter_otlp_proto_http-1.45.1-py3-none-any.whl", hash = "sha256:24a97cf3753c7fb52fad44a696e452ff371686339e2acf3309e2eda3d0230700", size = 22180, upload-time = "2026-10-06T17:32:43.946Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/03/89e47ff8d52a4f83b343e6eb9ef1698ff45357216e5b6b2b21e0da5c5c7d/opentelemetry_instrumentation-0.66b1.tar.gz", hash = "sha256:e79a510f7d87c72d95e964ddb42193a0d9a75668c027d980eab032ea1322a5ce", size = 43308, upload-time = "2026-10-06T17:36:10.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/b2/d1413681ff43e13ac9860df27e1226d3199ab0b97b352ceea41abcc660a5/opentelemetry_instrumentation-0.66b1-py3-none-any.whl", hash = "sha256:4c4aa14dc9a24a02325a9d4c42c4d0208dbb1374c2b1b8fe6c9392d59f3e1008", size = 36904, upload-time = "2026-10-06T17:35:11.663Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d9/ff522f5c3e340e9007554923b1a4d2ac451676f8757bafb3d0057f68b5c3/opentelemetry_instrumentation_asgi-0.66b1.tar.gz", hash = "sha256:78cdc5e45e897e16a8dac9d282e8d5bdf9af2d58e1313fa0bdd4a134c6f9dafc", size = 25992, upload-time = "2026-10-06T17:36:14.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/ea/10ba99110bf3c9fb736af39c96ca8f3668b988cabb6b59309e058c44461c/opentelemetry_instrumentation_asgi-0.66b1-py3-none-any.whl", hash = "sha256:78b3f9bdf0fa38c65935a2ab46d59e0f9de873a51e0c95b0329f106e2ccb5274", size = 15812, upload-time = "2026-10-06T17:35:17.638Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-dbapi" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/b4/35c682a3a5762547cab6ac1d0f6e66a4e549cbbb852862903bb1523036fe/opentelemetry_instrumentation_dbapi-0.66b1.tar.gz", hash = "sha256:715be3080f644de2314d84388a73571acdbeca85bfbc7dc682f214c88cc18022", size = 19990, upload-time = "2026-10-06T17:36:22.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/89/7f2ccd50975b66b2977a31bb489a2f0205b7fd033fc99046dcfa001c4252/opentelemetry_instrumentation_dbapi-0.66b1-py3-none-any.whl", hash = "sha256:a38043a8a907a321ae6b838f778ad19c8e78c0bfc2f336d6d30c5fdce112e88d", size = 14706, upload-time = "2026-10-06T17:35:28.831Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-django" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-wsgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/88/d0acbd7916aa3040a36486cedb60aa77c129c7029446fa6a00207345695c/opentelemetry_instrumentation_django-0.66b1.tar.gz", hash = "sha256:85b56193b6ce7b8db9c280c3ade4a4991601abc9ada4734c8911971423fd521f", size = 25233, upload-time = "2026-10-06T17:36:22.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/c3/2739c248b60b2802b9e51d2a3373269f0ca3e16059ea4f1ca7370502118c/opentelemetry_instrumentation_django-0.66b1-py3-none-any.whl", hash = "sha256:23d1052940a025ae1dac70849b998cf9ba66731fa2b7e58254ab7dac5539a989", size = 18914, upload-time = "2026-10-06T17:35:30.052Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/2a/cd4125b7acbea2ed17f1d31b58c184cb0a79fcb5541ceb4de90ffc6d8c01/opentelemetry_instrumentation_fastapi-0.66b1.tar.gz", hash = "sha256:584cf9d2c4417ff8b2d6ff2bc606bfe13c8b3456018bf94f50f2cf658492505b", size = 26634, upload-time = "2026-10-06T17:36:25.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/70/676928d537978acc7bff2ac8657bd0836ba608ffc8f65455238f1fa2bd0f/opentelemetry_instrumentation_fastapi-0.66b1-py3-none-any.whl", hash = "sha256:97f8ac8fd7537517f9e6988bd0aca04bfa5aad564bcd46c245530739e2be72d1", size = 14216, upload-time = "2026-10-06T17:35:32.827Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-flask" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-wsgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/fc/c1a692ceb7ed56818f2f4f88044b5a0895b6666da954dbd566fdf265195b/opentelemetry_instrumentation_flask-0.66b1.tar.gz", hash = "sha256:10f788aff95236f0dbaaa066687283bf6345bb3c77bbb437f0fb67cb6222d5eb", size = 23876, upload-time = "2026-10-06T17:36:25.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/65/7aa45ca677c51afb185bcd5e7ed121c84b07b2c566a72948a9e56fd16ae7/opentelemetry_instrumentation_flask-0.66b1-py3-none-any.whl", hash = "sha256:2933e71b94cc9e8e943e648d872fd3dead9cdb61295dc6deffd5d6fa8f27a39b", size = 14961, upload-time = "2026-10-06T17:35:34.002Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/50/41544799b043d14fdfa6fe62fa2518eaba22793fce03e9abde930b92e671/opentelemetry_instrumentation_httpx-0.66b1.tar.gz", hash = "sha256:5865a72c68098c85955a271ab8744b480a36e3ee492d35b8cadb93c7c4dbb618", size = 26637, upload-time = "2026-10-06T17:36:27.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/c6/e5682b1bfb320b32505e88255c34ae1e99fe9fb220cc465c244e91967eac/opentelemetry_instrumentation_httpx-0.66b1-py3-none-any.whl", hash = "sha256:0342a4002c6dbc6c4bf22cc7e698f50f5c8b77f63325c6f40c94ab87e016bf4d", size = 17723, upload-time = "2026-10-06T17:35:36.501Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-logging" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ca/0fcf525f238f4ab9df3a6d0423fb057085ea39a3e79ac912fef3d5fa16b8/opentelemetry_instrumentation_logging-0.66b1.tar.gz", hash = "sha256:2250240ecb1fce36939f64eafd988832338684564fb4c7b401c9b9af0ee11d46", size = 20353, upload-time = "2026-10-06T17:36:29.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/7b/76bea1e80787848a9ac9882a598cbf1e761f16cebdb399d0e675221e34a0/opentelemetry_instrumentation_logging-0.66b1-py3-none-any.whl", hash = "sha256:f13761110c52eecae9d7debedb0f88b46acc248c20437e97c2a5b3a2b4f96a02", size = 15928, upload-time = "2026-10-06T17:35:39.915Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-openai-agents-v2" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-genai" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/15/b6a303454d2800d772cdebc490c1d598d06d0e541619db80195eb9ea85c6/opentelemetry_instrumentation_openai_agents_v2-0.1.0.tar.gz", hash = "sha256:1033f4b261ce07f65d197ac0e9c499302c805eae987a6cc4e7f99bb279363477", size = 22423, upload-time = "2026-08-27T08:17:34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/0a/b6f47734e1d7f936cbc52ef8e673d3e08d9c3c8a13d9549c03f978758076/opentelemetry_instrumentation_openai_agents_v2-0.1.0-py3-none-any.whl", hash = "sha256:e4e3dfba32bd6eeee0624eca9be54341ab7cc4f7a3bb895354f2f9d6f7afe2f3", size = 25002, upload-time = "2026-07-26T03:21:54Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-openai-v2" +version = "2.3b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/4e/21f8cd16ccb471dd217ed85eb817796a10c4f2718ae2c91e752a57180cf0/opentelemetry_instrumentation_openai_v2-2.3b0.tar.gz", hash = "sha256:5de9d70cc9536eea1fe48ea016e0c5f25735fa9a13709076a64b20657fadb6ba", size = 170838, upload-time = "2026-08-27T07:51:29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/02/7ff0a9282520592772a356dd39d1559f3726610ccc3854a2f598b756c66f/opentelemetry_instrumentation_openai_v2-2.3b0-py3-none-any.whl", hash = "sha256:c6aca87be0da0289ea1d8167fea4b0f227ea5ef0e90496e2822121e47340d36a", size = 18053, upload-time = "2026-07-24T02:56:34Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-psycopg2" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-dbapi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/03/2b5babd54a905602097e37ce5fc0f20d99116625712363843ebf802410ef/opentelemetry_instrumentation_psycopg2-0.66b1.tar.gz", hash = "sha256:f7454114191930b854fa7a176d4009d727084ab2fb3bf4016c8ab0719ac89a6e", size = 12967, upload-time = "2026-10-06T17:36:32.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/7a/378b0f112d1219ff2ca04f0f491811923abcb832ccb71911c5ad12694c4a/opentelemetry_instrumentation_psycopg2-0.66b1-py3-none-any.whl", hash = "sha256:e69ef26a8e50d7ac2149a89b5e0f66c1fe5c06c3355a2068b845d83742139348", size = 11058, upload-time = "2026-10-06T17:35:44.612Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-requests" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/62/dcca0b7a2008675056040c61b70d37b5a24439613c7822068908ad3255ae/opentelemetry_instrumentation_requests-0.66b1.tar.gz", hash = "sha256:28578f72e68e3a5be3226c618ac9570ed360ef1dd22d5d6e0721c6905a4ccc67", size = 17985, upload-time = "2026-10-06T17:36:37.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/01/98e9bb7fef12784cd19811e454cf25bce4d774fd3e46b3f5fd2bce237075/opentelemetry_instrumentation_requests-0.66b1-py3-none-any.whl", hash = "sha256:7ba17d984a2bd876b88bf0aafcca27b5e3ff4c1821e0fe07641e380a3cf3a3a2", size = 13320, upload-time = "2026-10-06T17:35:52.477Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-urllib" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/21/626955d129e753d197217564f0688bef4ff62c05bbe9858b00112553604e/opentelemetry_instrumentation_urllib-0.66b1.tar.gz", hash = "sha256:f00daa25375fbdf88b7e1a425ef54c704674d8bef639c834de93df9b3f88d7f4", size = 16577, upload-time = "2026-10-06T17:36:42.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/47/b661cb253be58118cb27067b41fb001784ed615b5e42b5c59a4e30388ee4/opentelemetry_instrumentation_urllib-0.66b1-py3-none-any.whl", hash = "sha256:6f679d785ebcb04b791a889d8a9c0e8edf897a85ab80a7a12b0139c461b14627", size = 13076, upload-time = "2026-10-06T17:36:01.281Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-urllib3" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ab/2dd6b3a69df31ae12714bf24fcc2772956d7cb8f9082432c9e63a37e6930/opentelemetry_instrumentation_urllib3-0.66b1.tar.gz", hash = "sha256:31e84a2ccfa86ca1d4f1209ea66c1f709c335348d5c487477d3843172efcc408", size = 18826, upload-time = "2026-10-06T17:36:43.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/7f/16f82bf359c28fb71ad4dc5ad386cce3483abbf1ce0ade087fe78c18daf3/opentelemetry_instrumentation_urllib3-0.66b1-py3-none-any.whl", hash = "sha256:e48b448fed4a127e54c584c37a443c91d80d34b9f9de324af9a00cead8b338b8", size = 13462, upload-time = "2026-10-06T17:36:02.242Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-wsgi" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/7a/a1ba86d95f849db8f41ffd03c7cb302559ef091d76cff78baddd4ad19205/opentelemetry_instrumentation_wsgi-0.66b1.tar.gz", hash = "sha256:ce803d8e828e75d7225c25a618b692cbd4687a11be64356308ed1feab0f70bab", size = 19527, upload-time = "2026-10-06T17:36:44.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/4f/17f06b0dc7c542c482b1d3f7f1d0925046f7e156fb39c255634b3c4f526a/opentelemetry_instrumentation_wsgi-0.66b1-py3-none-any.whl", hash = "sha256:8c86390c32fe8d0924b3541f292122d3a2ba6c3accc2bcbcc73ae985ee8e799e", size = 13752, upload-time = "2026-10-06T17:36:03.166Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/7f/15f014fb195da6c2dbb6c71399b8e76824878718e94de6454038488eed28/opentelemetry_proto-1.45.1.tar.gz", hash = "sha256:79e0fb95e4616691a469439238aa9224d75779b3e108e895d1aa125ab29ca77c", size = 46488, upload-time = "2026-10-06T17:33:11.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/9a/42ec8180a769516ae757e893b69736826efceac7332553915b4528a91c6d/opentelemetry_proto-1.45.1-py3-none-any.whl", hash = "sha256:f38e2a8413053c180cd3d2637fbb279673ec2f6a6e09c995aafa2f452c52b46e", size = 72488, upload-time = "2026-10-06T17:32:53.057Z" }, +] + +[[package]] +name = "opentelemetry-resource-detector-azure" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/30/ae8e6630506e3c66aa73c865dedee4b6e305f08751e99388b474a0879d06/opentelemetry_resource_detector_azure-0.3.0.tar.gz", hash = "sha256:dc191af39d9e798b1bbb05a5620876c7599da961972a1f18ca7e8f7382ead519", size = 13874, upload-time = "2026-09-29T17:54:30.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/7d/7a1d0215ba5fc642da9f7b666be204bc6f222c6d55d3f4a3807b5c62e82a/opentelemetry_resource_detector_azure-0.3.0-py3-none-any.whl", hash = "sha256:6096d6a31a5371928b4fb399f9e6f3d06eb48fdbb7d07f44b15b19cdf3bab5cc", size = 14101, upload-time = "2026-09-29T17:54:28.932Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" }, +] + +[[package]] +name = "opentelemetry-util-genai" +version = "0.3b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/d8/4dd2fb622d26ec45b10ef63eb87fd512f5d7467c7bd35ce390629bd6dff8/opentelemetry_util_genai-0.3b0.tar.gz", hash = "sha256:83e127789a9ad615b8ca65f05fc36955a67ce257b06142bfd46159a3b7ed73d3", size = 31800, upload-time = "2026-08-27T07:48:46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/e5/fada54909e445d7b4007f8b96221d571999efeab9446f3127cc1cebe5e07/opentelemetry_util_genai-0.3b0-py3-none-any.whl", hash = "sha256:ebc2b01bcb891ddc7218452470d189d3321cd742653299ff8e7de45debcfb986", size = 28426, upload-time = "2026-07-24T03:05:41Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/b5/df4b61da899f6ebdffdbdf0c8b0f3189ee57151694ccd5b7d50ee2906241/opentelemetry_util_http-0.66b1.tar.gz", hash = "sha256:047dea1a628031f857a5a32261dc0e955bc162d39993ed1cffb8f2cff5ba8a62", size = 11586, upload-time = "2026-10-06T17:36:46.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/9b/c77ecaea79ba0de1a11e7f06a7f5eea7043ec23f1860dcf5f03536698e4c/opentelemetry_util_http-0.66b1-py3-none-any.whl", hash = "sha256:8f443d7abcaf29c4a07b373bbd31b5b39132c0ed3c27d015a59dc0323d5b1c58", size = 8387, upload-time = "2026-10-06T17:36:06.984Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-05T22:33:46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-05T06:15:43Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2026-09-15T01:04:14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2026-07-24T03:05:46Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-08-02T02:53:12Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-07-24T02:16:07Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-07-27T01:07:45Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-08-08T00:10:14Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-07-23T23:01:28Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-08-08T00:10:15Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-07-24T01:20:17Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-07-29T16:39:22Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-07-30T20:07:52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-07-24T09:35:01Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-07-31T17:19:16Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-07-31T17:19:10Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-07-31T17:19:08Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-07-31T17:19:03Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-07-31T17:19:18Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-07-29T23:15:15Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-07-31T17:19:21Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-07-31T17:19:04Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-07-31T17:19:12Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-07-31T17:19:14Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-07-31T17:19:00Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-07-24T16:22:26Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-07-25T01:16:09Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-07-23T19:03:38Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-07-26T05:27:53Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-07-31T17:18:53Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-07-31T17:18:56Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-07-24T01:05:45Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-07-27T20:05:57Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-08-11T15:37:37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-07-21T10:20:46Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-07-30T16:48:33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-07-23T18:54:46Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-07-31T21:51:57Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-07-23T18:46:22Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-07-30T20:43:00Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-07-21T10:20:34Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2026-07-31T17:23:50Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2026-07-23T18:51:42Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2026-07-31T22:51:34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2026-07-23T18:50:33Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-08-11T10:11:18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-28T01:53:54Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-08-02T06:16:35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-21T10:20:28Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-20T00:59:03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-19T12:38:02Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-08-04T05:36:09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-07-23T18:51:47Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" }, + { url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" }, + { url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" }, + { url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" }, + { url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, + { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, + { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, + { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, + { url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" }, + { url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" }, + { url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" }, + { url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" }, + { url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" }, + { url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" }, + { url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" }, + { url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" }, + { url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2026-08-03T19:09:08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2026-07-26T03:21:30Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/uv.toml b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/uv.toml new file mode 100644 index 000000000..159e430d3 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/uv.toml @@ -0,0 +1,2 @@ +system-certs = true +prerelease = "allow" diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/voice_runtime.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/voice_runtime.py new file mode 100644 index 000000000..cf1f8c602 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/voice_runtime.py @@ -0,0 +1,251 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Application-owned Voice Live Bridge Protocol 1.0 runtime.""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +from azure.ai.agentserver.invocations.voice import ( + ResponseAccepted, + ResponseCancelled, + ResponseDropped, + ResponseTimeout, + Session, + SessionDisconnected, + SessionEnd, + SessionReady, + SessionRejected, + SessionStart, + UserBargeIn, + UserMessage, + UserNoInput, + UserSpeechStarted, + VoiceAgentServerHost, +) + +from response_coordinator import ResponseCoordinator +from state import ( + InputClaim, + InputOrigin, + OperationOutcome, + SessionState, + SessionStore, +) + +logger = logging.getLogger("voice_live_bridge") + +SUPPORTED_PROTOCOL_VERSION = "1.0" +IDLE_REMINDER_TEXT = "Are you still there?" + + +class VoiceRuntime: + """Application-owned lifecycle for typed Voice Live Bridge callbacks.""" + + def __init__(self) -> None: + self._store = SessionStore() + self._coordinator = ResponseCoordinator() + self._closed = False + + def bind(self, app: VoiceAgentServerHost) -> None: + """Register all callbacks on one Voice host.""" + + @app.on_session_start + async def session_start(session: Session, event: SessionStart) -> None: + await self.on_session_start(session, event) + + @app.on_user_message + async def user_message(session: Session, event: UserMessage) -> None: + await self.on_user_message(session, event) + + @app.on_user_no_input + async def user_no_input(session: Session, event: UserNoInput) -> None: + await self.on_user_no_input(session, event) + + @app.on_user_speech_started + async def user_speech_started(session: Session, event: UserSpeechStarted) -> None: + await self.on_user_speech_started(session, event) + + @app.on_user_barge_in + async def user_barge_in(session: Session, event: UserBargeIn) -> None: + await self.on_user_barge_in(session, event) + + @app.on_response_accepted + async def response_accepted(session: Session, event: ResponseAccepted) -> None: + await self.on_response_accepted(session, event) + + @app.on_response_dropped + async def response_dropped(session: Session, event: ResponseDropped) -> None: + await self.on_response_dropped(session, event) + + @app.on_response_cancelled + async def response_cancelled(session: Session, event: ResponseCancelled) -> None: + await self.on_response_cancelled(session, event) + + @app.on_response_timeout + async def response_timeout(session: Session, event: ResponseTimeout) -> None: + await self.on_response_timeout(session, event) + + @app.on_session_end + async def session_end(session: Session, event: SessionEnd) -> None: + await self.on_session_end(session, event) + + @app.on_disconnect + async def disconnected(session: Session, event: SessionDisconnected) -> None: + del session + logger.info("Voice transport disconnected; close_code=%d", event.code) + + @app.on_connection_terminating + def terminating(session: Session) -> None: + self.on_connection_terminating(session) + + @app.shutdown_handler + async def shutdown() -> None: + await self.close() + + async def on_session_start(self, session: Session, event: SessionStart) -> None: + if self._closed: + return + state = self._store.get_or_create(session) + if not state.try_begin_start(): + logger.warning("Ignoring duplicate or late session.start") + return + if event.protocol_version != SUPPORTED_PROTOCOL_VERSION: + state.reject() + await session.send(SessionRejected(code="protocol_mismatch", retriable=False)) + return + if os.getenv("VOICE_SAMPLE_REJECT_START", "").lower() == "true": + state.reject() + await session.send( + SessionRejected( + code="startup_failed", + retriable=True, + message="VOICE_SAMPLE_REJECT_START is enabled.", + ) + ) + return + try: + await session.send(SessionReady()) + state.activate() + except BaseException: + state.terminating = True + raise + + async def on_user_message(self, session: Session, event: UserMessage) -> None: + """The field-operations subclass supplies the business output stream.""" + raise NotImplementedError + + async def on_user_no_input(self, session: Session, event: UserNoInput) -> None: + state = self._state_for(session) + if state is None: + return + if not await self._claim_input(session, state, event.item_id, InputOrigin.NO_INPUT): + return + if event.count >= 3: + self._coordinator.detach_terminating_state(state, OperationOutcome.END_CALL) + self._coordinator.schedule_end_call(session, state, InputOrigin.NO_INPUT) + return + if not state.can_start_response(): + self._coordinator.schedule_none( + session, + state, + event.item_id, + InputOrigin.NO_INPUT, + reason="capacity_exceeded", + ) + return + self._coordinator.start_response( + session, + state, + event.item_id, + text=IDLE_REMINDER_TEXT, + streaming=False, + voice=None, + ) + + async def on_user_speech_started(self, session: Session, event: UserSpeechStarted) -> None: + del event + if self._state_for(session) is not None: + logger.debug("Caller speech started") + + async def on_user_barge_in(self, session: Session, event: UserBargeIn) -> None: + state = self._state_for(session) + if state is not None: + self._coordinator.barge_in(state, event.response_id, event.heard_text) + + async def on_response_accepted(self, session: Session, event: ResponseAccepted) -> None: + state = self._state_for(session) + if state is not None: + await self._coordinator.accept_proactive(session, state, event.response_id) + + async def on_response_dropped(self, session: Session, event: ResponseDropped) -> None: + state = self._state_for(session) + if state is not None: + state.proactive_requests.pop(event.response_id, None) + + async def on_response_cancelled(self, session: Session, event: ResponseCancelled) -> None: + state = self._state_for(session) + if state is not None: + self._coordinator.response_cancelled(state, event.response_id, event.heard_text) + + async def on_response_timeout(self, session: Session, event: ResponseTimeout) -> None: + state = self._state_for(session) + if state is not None: + self._coordinator.response_timeout(state, event.response_id, event.item_ids) + + async def on_session_end(self, session: Session, event: SessionEnd) -> None: + del event + state = self._store.remove(session) + if state is not None: + self._coordinator.detach_terminating_state(state, OperationOutcome.END_CALL) + + def on_connection_terminating(self, session: Session) -> None: + state = self._store.remove(session) + if state is not None: + self._coordinator.detach_terminating_state(state, OperationOutcome.TRANSPORT_ERROR) + + def _state_for(self, session: Session) -> SessionState | None: + return self._store.active(session) + + async def _claim_input( + self, + session: Session, + state: SessionState, + item_id: str, + origin: InputOrigin, + ) -> bool: + claim = state.claim_input(item_id) + if claim is InputClaim.CLAIMED: + return True + if claim is InputClaim.INACTIVE: + return False + if claim is InputClaim.DUPLICATE: + logger.warning("Ignoring duplicate Voice input item") + return False + self._coordinator.detach_terminating_state(state, OperationOutcome.END_CALL) + self._coordinator.schedule_end_call( + session, + state, + origin, + reason="session_input_limit", + ) + return False + + async def close(self) -> None: + """Cancel all sessions and finish detached response tasks once.""" + if not self._closed: + self._closed = True + for state in self._store.drain(): + self._coordinator.detach_terminating_state(state, OperationOutcome.CANCELLED) + await self._coordinator.wait_for_detached_tasks() + + +def create_app(**host_options: Any) -> VoiceAgentServerHost: + """Create a Voice host with isolated application state.""" + runtime = VoiceRuntime() + app = VoiceAgentServerHost(**host_options) + runtime.bind(app) + app.state.voice_runtime = runtime + return app diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/worker_agent.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/worker_agent.py new file mode 100644 index 000000000..c9c47b322 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/foreground_background/src/foreground-background-agents-voicelive/worker_agent.py @@ -0,0 +1,352 @@ +"""Worker Agent — background tool executor. + +Runs the multi-round tool-calling loop asynchronously. Supports cancellation +between rounds and reports progress to the shared TaskStore. + +This agent never talks to the user directly — it writes results into the Task. +""" + +import asyncio +import json +import logging +import time + +from task_store import MAX_TASK_RESULT_CHARACTERS, Task, TaskStatus + +logger = logging.getLogger(__name__) + +_MAX_TOOL_ROUNDS = 5 + +_WORKER_SYSTEM_PROMPT = ( + "You are a field operations AI assistant for data center technicians at" + " Microsoft.\n\nYou help on-site technicians with:\n- Looking up site spec" + "ifications and fiber termination details\n- Finding relevant context fr" + "om Teams and Outlook (Work IQ)\n- Retrieving repair and maintenance pro" + "cedures\n- Analyzing technical documents and schematics\n\nYou are voice-" + "enabled — technicians talk to you hands-free while working on site.\nKe" + "ep responses concise and actionable. When referencing technical detail" + "s, be specific\nabout panel numbers, rack locations, and fiber types.\n\n" + "Always cross-reference multiple sources when available — check both si" + "te specs AND\nrecent communications to give the most up-to-date answer." +) + +# ── Domain Tools ────────────────────────────────────────────────────────────── + +DOMAIN_TOOLS = [ + { + "type": "function", + "name": "search_site_specs", + "description": ( + "Search site specification documents for a data center site. Returns te" + "chnical details about fiber panels, network topology, power systems, a" + "nd site layout." + ), + "parameters": { + "type": "object", + "properties": { + "site_name": { + "type": "string", + "description": ( + "Name of the data center site (e.g., 'Quincy North', 'East US DC-7')" + ), + }, + "query": {"type": "string", "description": "What to search for in the site specs"}, + }, + "required": ["site_name", "query"], + }, + }, + { + "type": "function", + "name": "search_work_iq", + "description": ( + "Search Work IQ (Microsoft 365) for relevant context from Teams message" + "s, Outlook emails, and People directory. Returns recent communications" + " about a topic." + ), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query for Teams/Outlook/People"}, + "source": { + "type": "string", + "enum": ["teams", "outlook", "people", "all"], + "description": "Which Work IQ source to search", + }, + }, + "required": ["query"], + }, + }, + { + "type": "function", + "name": "get_repair_procedure", + "description": ( + "Retrieve a repair or maintenance procedure from procedural memory. Ret" + "urns step-by-step instructions learned from past incidents." + ), + "parameters": { + "type": "object", + "properties": { + "procedure_type": { + "type": "string", + "description": ( + "Type of procedure (e.g., 'fiber_splice', " + "'panel_replacement', 'power_failover')" + ), + }, + "site_name": { + "type": "string", + "description": "Optional site name for site-specific procedures", + }, + }, + "required": ["procedure_type"], + }, + }, + { + "type": "function", + "name": "analyze_document", + "description": ( + "Use Document Understanding to analyze a technical document, schematic," + " or form. Extracts structured data from complex multi-page documents." + ), + "parameters": { + "type": "object", + "properties": { + "document_id": {"type": "string", "description": "Document identifier or name"}, + "question": { + "type": "string", + "description": "What to extract or analyze from the document", + }, + }, + "required": ["document_id", "question"], + }, + }, +] + +# ── Mock Tool Implementations ───────────────────────────────────────────────── +# Feature teams replace these with real backends. + +_MOCK_RESPONSES = { + "search_site_specs": lambda args: json.dumps( + { + "site": args.get("site_name", "Unknown"), + "results": [ + { + "title": f"Fiber Termination Spec — {args.get('site_name', 'Site')}", + "content": f"Panel layout for {args.get('site_name', 'site')}: " + f"A-side connections on panels 1-12 (single-mode OS2), " + f"B-side connections on panels 13-24 (multi-mode OM4). " + f"Cross-connect patch panel is in Row C, Rack 7. " + f"Last updated: 2026-04-15.", + }, + { + "title": f"Network Topology — {args.get('site_name', 'Site')}", + "content": ( + "Primary uplink: 100G to regional hub via dark fiber pair F-201/F-202. " + "Redundant path: 40G via carrier XYZ on diverse route." + ), + }, + ], + } + ), + "search_work_iq": lambda args: json.dumps( + { + "source": args.get("source", "all"), + "results": [ + { + "type": "teams_message", + "from": "Sarah Chen (Site Supervisor)", + "date": "2026-05-10", + "content": f"Re: {args.get('query', 'topic')} — Confirmed the B-side panels " + f"were re-terminated last week. New fiber map is in the site binder.", + }, + { + "type": "outlook_email", + "from": "Network Operations", + "date": "2026-05-09", + "subject": "Maintenance Window Completed", + "snippet": "All splice work completed on the north campus fiber ring.", + }, + ], + } + ), + "get_repair_procedure": lambda args: json.dumps( + { + "procedure": args.get("procedure_type", "general"), + "steps": [ + "1. Verify affected circuit using OTDR test from patch panel", + "2. Identify splice point using fiber map (Row C, Rack 7)", + "3. Clean all connectors with IPA wipes before re-termination", + "4. Use fusion splicer for single-mode (OS2) connections", + "5. Verify continuity with visual fault locator", + "6. Run OTDR baseline and compare to previous readings", + "7. Update fiber map and close work order", + ], + "notes": "Learned from 47 previous incidents at this site type.", + } + ), + "analyze_document": lambda args: json.dumps( + { + "document": args.get("document_id", "unknown"), + "analysis": f"Document analysis for '{args.get('document_id', 'doc')}': " + f"This is a multi-page site specification containing fiber topology diagrams, " + f"power distribution schematics, and equipment inventory. " + f"Key finding: {args.get('question', 'N/A')}", + "extracted_data": { + "total_pages": 47, + "diagrams": 12, + "tables": 8, + }, + } + ), +} + + +def _execute_tool(name: str, arguments: dict) -> str: + """Execute a tool and return the result string (with simulated latency).""" + import random + + time.sleep(random.randint(6, 12)) # simulate network/service latency for testing + handler = _MOCK_RESPONSES.get(name) + if handler: + return handler(arguments) + return json.dumps({"error": f"Unknown tool: {name}"}) + + +# ── Worker Loop ─────────────────────────────────────────────────────────────── + + +async def run_worker(task: Task, input_items: list[dict], responses_client, model: str): + """Run the tool-calling loop in the background for a given task. + + Updates task.status/current_tool/result as it progresses. + Checks task.cancel_event between rounds — but always finishes the current + in-flight operation so its result is preserved. The handler doesn't block + on the worker. Response interruption does not cancel the Worker. Session + teardown signals cancel_event and retains it until its current call settles. + """ + task.status = TaskStatus.RUNNING + loop = asyncio.get_running_loop() + + try: + for round_num in range(_MAX_TOOL_ROUNDS): + # ── Cancellation checkpoint (between rounds) ── + if task.cancel_event.is_set(): + task.status = TaskStatus.CANCELLED + task.result = "(Cancelled by user)" + return + + task.rounds_completed = round_num + + # ── LLM call ── + def create_response(): + # Skip work that was cancelled while waiting in the executor queue. + if task.cancel_event.is_set(): + return None + return responses_client.create( + model=model, + instructions=_WORKER_SYSTEM_PROMPT, + input=input_items, + tools=DOMAIN_TOOLS, + store=False, + include=["reasoning.encrypted_content"], + ) + + response = await loop.run_in_executor(None, create_response) + if response is None: + task.status = TaskStatus.CANCELLED + task.result = "(Cancelled by user)" + return + if getattr(response, "status", None) != "completed": + raise ValueError("Worker response did not complete successfully") + + tool_calls = [ + item for item in response.output if getattr(item, "type", None) == "function_call" + ] + refused = any( + part.type == "refusal" + for item in response.output if item.type == "message" + for part in item.content + ) + text = response.output_text + + # Check cancel after LLM completes + if task.cancel_event.is_set(): + # Preserve a genuine final answer, not a refusal or empty placeholder. + if text.strip() and not tool_calls and not refused: + task.status = TaskStatus.COMPLETED + task.result = text + else: + task.status = TaskStatus.CANCELLED + task.result = "(Cancelled by user)" + return + + if refused: + raise ValueError("Worker response refused the request") + # No tool calls → model produced final answer + if not tool_calls: + if not text.strip(): + raise ValueError("Worker response contained no answer") + task.status = TaskStatus.COMPLETED + task.result = text + return + + # Replay the complete model output, including reasoning, before tool results. + input_items.extend(response.output) + + # ── Execute each tool call ── + for tc in tool_calls: + task.current_tool = tc.name + + try: + arguments = ( + json.loads(tc.arguments) if isinstance(tc.arguments, str) else tc.arguments + ) + + def execute_tool(name=tc.name, tool_arguments=arguments): + # Like model calls, queued tools must check cancellation at entry. + if task.cancel_event.is_set(): + return None + return _execute_tool(name, tool_arguments) + + result_text = await loop.run_in_executor(None, execute_tool) + if result_text is None: + task.status = TaskStatus.CANCELLED + task.result = "(Cancelled by user)" + return + logger.info("Tool '%s' returned %d chars", tc.name, len(result_text)) + except Exception as e: + logger.error("Tool failed; error_type=%s", type(e).__name__) + result_text = "The tool failed. Try another available source." + + input_items.append( + { + "type": "function_call_output", + "call_id": tc.call_id, + "output": result_text, + } + ) + + # Check cancel between tool calls + if task.cancel_event.is_set(): + task.status = TaskStatus.CANCELLED + task.result = "(Cancelled by user)" + return + + task.current_tool = None + + # Keep the five-call bound; tool outputs without a final answer are not success. + raise ValueError("Worker exhausted its tool-round budget without a final answer") + + except Exception as e: + logger.error("Worker failed; error_type=%s", type(e).__name__) + task.status = TaskStatus.FAILED + task.result = "The background work failed." + finally: + # Include final answers that arrive after cooperative cancellation was requested. + if ( + task.status is TaskStatus.COMPLETED + and len(task.result or "") > MAX_TASK_RESULT_CHARACTERS + ): + task.status = TaskStatus.FAILED + task.result = "The background result exceeded this sample's size limit." + task.finished_at = time.time() diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/README.md b/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/README.md index 611fb093f..7e764f6e8 100644 --- a/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/README.md +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/README.md @@ -239,7 +239,9 @@ After deployment, verify readiness and a spoken echo through the Voice wrapper. `state.py` owns connection state and response/input-ID correlation. - `user.barge_in` and `response.cancelled` synchronize caller-heard text and cancel pending output. `response.timeout` cancels work selected by response ID or input IDs. -- Playback state is bounded to 64 known responses with up to 8,000 characters of heard text each. +- Playback state is bounded to 64 known responses, each with at most 512 KiB of UTF-8 heard text, + matching the response output budget rather than the 8,000-character input limit. Larger values + are cut only at complete Unicode characters; string/object overhead is additional to this budget. Session termination clears it together with pending work. - Response operations contain protocol bookkeeping, not `prompt` or `use_model` flags. - Runtime dependencies are locked in `uv.lock`; OpenAI, LangChain, and LangGraph clients diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/response_coordinator.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/response_coordinator.py index 4c0d08801..0ebd071d8 100644 --- a/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/response_coordinator.py +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/response_coordinator.py @@ -29,6 +29,7 @@ from response_telemetry import ResponseTelemetry from state import ( + MAX_OUTPUT_BYTES, MAX_PENDING_PROACTIVE, MAX_TERMINAL_SENDS, ApplicationTurn, @@ -40,7 +41,6 @@ ) logger = logging.getLogger("voice_live_bridge") -MAX_OUTPUT_BYTES = 512 * 1024 MAX_OUTPUT_CHUNKS = 4096 class ResponseCoordinator: @@ -381,6 +381,8 @@ async def wait_for_detached_tasks(self) -> None: *(asyncio.shield(task) for task in tuple(self._detached_tasks)), return_exceptions=True, ) + # gather() can finish synchronously; let ownership-release callbacks run. + await asyncio.sleep(0) def schedule_none( self, session: Session, state: SessionState, input_item_id: str, diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/state.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/state.py index f7aa66878..8e14f52fa 100644 --- a/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/state.py +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/state.py @@ -18,6 +18,7 @@ MAX_TRACKED_PLAYBACK_RESPONSES = 64 MAX_MESSAGE_CHARACTERS = 8_000 +MAX_OUTPUT_BYTES = 512 * 1024 MAX_ACTIVE_RESPONSES = 8 MAX_TERMINAL_SENDS = 8 MAX_PENDING_PROACTIVE = 8 @@ -157,8 +158,12 @@ def record_heard_text(self, response_id: str, heard_text: str) -> None: """Synchronize only responses this connection has actually tracked.""" if response_id not in self.heard_text_by_response and response_id not in self.responses: return + # Playback describes response output, not user input. Bound both the UTF-8 + # payload and temporary allocation; malformed escapes must not block cancellation. + encoded = heard_text[:MAX_OUTPUT_BYTES].encode("utf-8", errors="replace") + bounded = encoded[:MAX_OUTPUT_BYTES].decode("utf-8", errors="ignore") self.track_response(response_id) - self.heard_text_by_response[response_id] = heard_text[:MAX_MESSAGE_CHARACTERS] + self.heard_text_by_response[response_id] = bounded @property def terminating(self) -> bool: diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/tests/test_heard_text_budget.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/tests/test_heard_text_budget.py new file mode 100644 index 000000000..2925d0a23 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/tests/test_heard_text_budget.py @@ -0,0 +1,79 @@ +"""Heard prefixes use the response byte budget, not the user-input limit.""" + +import pytest + +from response_coordinator import MAX_OUTPUT_BYTES +from state import MAX_MESSAGE_CHARACTERS, MAX_TRACKED_PLAYBACK_RESPONSES, SessionState + + +@pytest.mark.parametrize( + "unit,count,tail,expected_count", + [ + ("x", 7999, "", 7999), + ("x", 8000, "", 8000), + ("x", 8001, "", 8001), + ("x", 8011, "", 8011), + ("👋", 8001, "", 8001), + ("x", 524288, "", 524288), + ("x", 524289, "", 524288), + ("👋", 131072, "", 131072), + ("👋", 131073, "", 131072), + ("界", 174763, "", 174762), + ("x", 524287, "👋", 524287), + ], + ids=[ + "below-input-limit", + "at-input-limit", + "above-input-limit", + "result-plus-ack", + "long-unicode", + "exact-byte-budget", + "over-byte-budget", + "unicode-exact-budget", + "unicode-over-budget", + "three-byte-boundary", + "partial-rune-must-not-be-retained", + ], +) +def test_heard_prefix_is_complete_within_output_budget_and_bounded_above_it( + unit: str, + count: int, + tail: str, + expected_count: int, +) -> None: + state = SessionState() + state.track_response("r_known") + heard = unit * count + tail + state.record_heard_text("r_known", heard) + stored = state.heard_text_by_response["r_known"] + assert len(stored) == len(unit * expected_count) + assert stored == unit * expected_count + assert heard.startswith(stored) + assert len(stored.encode("utf-8")) <= 512 * 1024 + if len(heard.encode("utf-8")) <= 512 * 1024: + assert stored == heard + state.record_heard_text("r_known", "") + assert state.heard_text_by_response["r_known"] == "" + state.record_heard_text("r_known", "retry prefix") + assert state.heard_text_by_response == {"r_known": "retry prefix"} + + +def test_unknown_and_evicted_responses_cannot_repopulate_playback_cache() -> None: + state = SessionState() + state.record_heard_text("r_unknown", "x" * 8011) + assert not state.heard_text_by_response + for index in range(MAX_TRACKED_PLAYBACK_RESPONSES + 1): + state.track_response(f"r_{index}") + state.record_heard_text("r_0", "evicted prefix") + assert "r_0" not in state.heard_text_by_response + assert len(state.heard_text_by_response) == 64 + state.record_heard_text("r_64", "x" * 8011) + assert len(state.heard_text_by_response["r_64"]) == 8011 + assert sum(len(text.encode("utf-8")) for text in state.heard_text_by_response.values()) <= ( + 64 * 512 * 1024 + ) + + +def test_response_budget_does_not_expand_user_input_or_output_limits() -> None: + assert MAX_MESSAGE_CHARACTERS == 8000 + assert MAX_OUTPUT_BYTES == 512 * 1024 diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/tests/test_shutdown_checkpoint.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/tests/test_shutdown_checkpoint.py new file mode 100644 index 000000000..b79a71b1a --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/langchain/src/voice-live-bridge-langchain-python/tests/test_shutdown_checkpoint.py @@ -0,0 +1,17 @@ +"""Drain must progress when all retained tasks finished before the join.""" + +import asyncio + +import pytest + +from response_coordinator import ResponseCoordinator + + +@pytest.mark.asyncio +async def test_drain_yields_to_completed_task_release_callback() -> None: + coordinator = ResponseCoordinator() + task = asyncio.create_task(asyncio.sleep(0)) + await task + coordinator._retain_task(task) + await asyncio.wait_for(coordinator.wait_for_detached_tasks(), 1) + assert not coordinator._detached_tasks diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/README.md b/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/README.md index 6c8fe7705..3a9b27d9c 100644 --- a/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/README.md +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/README.md @@ -239,7 +239,9 @@ After deployment, verify readiness and a spoken echo through the Voice wrapper. `state.py` owns connection state and response/input-ID correlation. - `user.barge_in` and `response.cancelled` synchronize caller-heard text and cancel pending output. `response.timeout` cancels work selected by response ID or input IDs. -- Playback state is bounded to 64 known responses with up to 8,000 characters of heard text each. +- Playback state is bounded to 64 known responses, each with at most 512 KiB of UTF-8 heard text, + matching the response output budget rather than the 8,000-character input limit. Larger values + are cut only at complete Unicode characters; string/object overhead is additional to this budget. Session termination clears it together with pending work. - Response operations contain protocol bookkeeping, not `prompt` or `use_model` flags. - Runtime dependencies are locked in `uv.lock`; OpenAI, LangChain, and LangGraph clients diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/response_coordinator.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/response_coordinator.py index fc06dc3d4..45f2f0b97 100644 --- a/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/response_coordinator.py +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/response_coordinator.py @@ -29,6 +29,7 @@ from response_telemetry import ResponseTelemetry from state import ( + MAX_OUTPUT_BYTES, MAX_PENDING_PROACTIVE, MAX_TERMINAL_SENDS, ApplicationTurn, @@ -40,7 +41,6 @@ ) logger = logging.getLogger("voice_live_bridge") -MAX_OUTPUT_BYTES = 512 * 1024 MAX_OUTPUT_CHUNKS = 4096 class ResponseCoordinator: @@ -381,6 +381,8 @@ async def wait_for_detached_tasks(self) -> None: *(asyncio.shield(task) for task in tuple(self._detached_tasks)), return_exceptions=True, ) + # gather() can finish synchronously; let ownership-release callbacks run. + await asyncio.sleep(0) def schedule_none( self, session: Session, state: SessionState, input_item_id: str, diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/state.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/state.py index f7aa66878..8e14f52fa 100644 --- a/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/state.py +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/state.py @@ -18,6 +18,7 @@ MAX_TRACKED_PLAYBACK_RESPONSES = 64 MAX_MESSAGE_CHARACTERS = 8_000 +MAX_OUTPUT_BYTES = 512 * 1024 MAX_ACTIVE_RESPONSES = 8 MAX_TERMINAL_SENDS = 8 MAX_PENDING_PROACTIVE = 8 @@ -157,8 +158,12 @@ def record_heard_text(self, response_id: str, heard_text: str) -> None: """Synchronize only responses this connection has actually tracked.""" if response_id not in self.heard_text_by_response and response_id not in self.responses: return + # Playback describes response output, not user input. Bound both the UTF-8 + # payload and temporary allocation; malformed escapes must not block cancellation. + encoded = heard_text[:MAX_OUTPUT_BYTES].encode("utf-8", errors="replace") + bounded = encoded[:MAX_OUTPUT_BYTES].decode("utf-8", errors="ignore") self.track_response(response_id) - self.heard_text_by_response[response_id] = heard_text[:MAX_MESSAGE_CHARACTERS] + self.heard_text_by_response[response_id] = bounded @property def terminating(self) -> bool: diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/tests/test_heard_text_budget.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/tests/test_heard_text_budget.py new file mode 100644 index 000000000..2925d0a23 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/tests/test_heard_text_budget.py @@ -0,0 +1,79 @@ +"""Heard prefixes use the response byte budget, not the user-input limit.""" + +import pytest + +from response_coordinator import MAX_OUTPUT_BYTES +from state import MAX_MESSAGE_CHARACTERS, MAX_TRACKED_PLAYBACK_RESPONSES, SessionState + + +@pytest.mark.parametrize( + "unit,count,tail,expected_count", + [ + ("x", 7999, "", 7999), + ("x", 8000, "", 8000), + ("x", 8001, "", 8001), + ("x", 8011, "", 8011), + ("👋", 8001, "", 8001), + ("x", 524288, "", 524288), + ("x", 524289, "", 524288), + ("👋", 131072, "", 131072), + ("👋", 131073, "", 131072), + ("界", 174763, "", 174762), + ("x", 524287, "👋", 524287), + ], + ids=[ + "below-input-limit", + "at-input-limit", + "above-input-limit", + "result-plus-ack", + "long-unicode", + "exact-byte-budget", + "over-byte-budget", + "unicode-exact-budget", + "unicode-over-budget", + "three-byte-boundary", + "partial-rune-must-not-be-retained", + ], +) +def test_heard_prefix_is_complete_within_output_budget_and_bounded_above_it( + unit: str, + count: int, + tail: str, + expected_count: int, +) -> None: + state = SessionState() + state.track_response("r_known") + heard = unit * count + tail + state.record_heard_text("r_known", heard) + stored = state.heard_text_by_response["r_known"] + assert len(stored) == len(unit * expected_count) + assert stored == unit * expected_count + assert heard.startswith(stored) + assert len(stored.encode("utf-8")) <= 512 * 1024 + if len(heard.encode("utf-8")) <= 512 * 1024: + assert stored == heard + state.record_heard_text("r_known", "") + assert state.heard_text_by_response["r_known"] == "" + state.record_heard_text("r_known", "retry prefix") + assert state.heard_text_by_response == {"r_known": "retry prefix"} + + +def test_unknown_and_evicted_responses_cannot_repopulate_playback_cache() -> None: + state = SessionState() + state.record_heard_text("r_unknown", "x" * 8011) + assert not state.heard_text_by_response + for index in range(MAX_TRACKED_PLAYBACK_RESPONSES + 1): + state.track_response(f"r_{index}") + state.record_heard_text("r_0", "evicted prefix") + assert "r_0" not in state.heard_text_by_response + assert len(state.heard_text_by_response) == 64 + state.record_heard_text("r_64", "x" * 8011) + assert len(state.heard_text_by_response["r_64"]) == 8011 + assert sum(len(text.encode("utf-8")) for text in state.heard_text_by_response.values()) <= ( + 64 * 512 * 1024 + ) + + +def test_response_budget_does_not_expand_user_input_or_output_limits() -> None: + assert MAX_MESSAGE_CHARACTERS == 8000 + assert MAX_OUTPUT_BYTES == 512 * 1024 diff --git a/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/tests/test_shutdown_checkpoint.py b/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/tests/test_shutdown_checkpoint.py new file mode 100644 index 000000000..b79a71b1a --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/conversation_engine/langgraph/src/voice-live-bridge-langgraph-python/tests/test_shutdown_checkpoint.py @@ -0,0 +1,17 @@ +"""Drain must progress when all retained tasks finished before the join.""" + +import asyncio + +import pytest + +from response_coordinator import ResponseCoordinator + + +@pytest.mark.asyncio +async def test_drain_yields_to_completed_task_release_callback() -> None: + coordinator = ResponseCoordinator() + task = asyncio.create_task(asyncio.sleep(0)) + await task + coordinator._retain_task(task) + await asyncio.wait_for(coordinator.wait_for_detached_tasks(), 1) + assert not coordinator._detached_tasks