diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f16b36c3..339a978e 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -35,6 +35,8 @@ on: - packages/langchain-agents - packages/langchain-messages - packages/opencode-agents + - packages/bedrock-agents + - packages/bedrock-messages - packages/vercel-agents - packages/vercel-messages dry_run: @@ -73,6 +75,10 @@ jobs: package-langchain-messages-tag-name: ${{ steps.release.outputs['packages/langchain-messages--tag_name'] }} package-opencode-agents-released: ${{ steps.release.outputs['packages/opencode-agents--release_created'] }} package-opencode-agents-tag-name: ${{ steps.release.outputs['packages/opencode-agents--tag_name'] }} + package-bedrock-agents-released: ${{ steps.release.outputs['packages/bedrock-agents--release_created'] }} + package-bedrock-agents-tag-name: ${{ steps.release.outputs['packages/bedrock-agents--tag_name'] }} + package-bedrock-messages-released: ${{ steps.release.outputs['packages/bedrock-messages--release_created'] }} + package-bedrock-messages-tag-name: ${{ steps.release.outputs['packages/bedrock-messages--tag_name'] }} package-vercel-agents-released: ${{ steps.release.outputs['packages/vercel-agents--release_created'] }} package-vercel-agents-tag-name: ${{ steps.release.outputs['packages/vercel-agents--tag_name'] }} package-vercel-messages-released: ${{ steps.release.outputs['packages/vercel-messages--release_created'] }} @@ -424,6 +430,68 @@ jobs: password: ${{ env.PYPI_AUTH_TOKEN }} packages-dir: packages/opencode-agents/dist/ + release-bedrock-agents: + runs-on: ubuntu-latest + needs: ['release-please'] + permissions: + contents: read + id-token: write + attestations: write + if: ${{ needs.release-please.outputs.package-bedrock-agents-released == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + workspace_path: packages/bedrock-agents + - uses: ./.github/actions/build + with: + workspace_path: packages/bedrock-agents + - name: Attest build provenance + uses: actions/attest@v4 + with: + subject-path: 'packages/bedrock-agents/dist/*' + - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0 + name: 'Get PyPI token' + with: + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + ssm_parameter_pairs: '/production/common/releasing/pypi/token = PYPI_AUTH_TOKEN' + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + password: ${{ env.PYPI_AUTH_TOKEN }} + packages-dir: packages/bedrock-agents/dist/ + + release-bedrock-messages: + runs-on: ubuntu-latest + needs: ['release-please'] + permissions: + contents: read + id-token: write + attestations: write + if: ${{ needs.release-please.outputs.package-bedrock-messages-released == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + workspace_path: packages/bedrock-messages + - uses: ./.github/actions/build + with: + workspace_path: packages/bedrock-messages + - name: Attest build provenance + uses: actions/attest@v4 + with: + subject-path: 'packages/bedrock-messages/dist/*' + - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0 + name: 'Get PyPI token' + with: + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + ssm_parameter_pairs: '/production/common/releasing/pypi/token = PYPI_AUTH_TOKEN' + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + password: ${{ env.PYPI_AUTH_TOKEN }} + packages-dir: packages/bedrock-messages/dist/ + release-vercel-agents: runs-on: ubuntu-latest needs: ['release-please'] diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3ae47d9b..f2235f47 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -11,5 +11,7 @@ "packages/vercel-messages": "0.1.0", "packages/vercel-agents": "0.1.0", "packages/litellm-messages": "0.1.0", - "packages/litellm-agents": "0.1.0" + "packages/litellm-agents": "0.1.0", + "packages/bedrock-agents": "0.0.1", + "packages/bedrock-messages": "0.0.1" } diff --git a/AGENTS.md b/AGENTS.md index 10d181d6..ebf4f668 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ graph TD - **Tier 0 — Core** (`launchdarkly-ai-server`): The foundation. Owns all LaunchDarkly integration, telemetry orchestration, shared data types, and the primary entry points (`config()`, `graph()`, `resolve_graph()`). Has no dependency on any other `launchdarkly-ai-*` package. - **Tier 0 — Convenience barrel** (`launchdarkly-ai-python`): A pure re-export package that makes all of `launchdarkly-ai-server` available under a shorter install name. No new logic — intended as the default install for most Python applications. -- **Tier 1 — Handler packages** (`launchdarkly-ai-claude-agents`, `launchdarkly-ai-claude-messages`, `launchdarkly-ai-openai-agents`, `launchdarkly-ai-openai-messages`, `launchdarkly-ai-langchain-agents`, `launchdarkly-ai-langchain-messages`, `launchdarkly-ai-litellm-agents`, `launchdarkly-ai-litellm-messages`, `launchdarkly-ai-opencode-agents`, `launchdarkly-ai-vercel-agents`, `launchdarkly-ai-vercel-messages`, …): Each wraps a specific AI provider SDK. Depends on `launchdarkly-ai-server` for shared types and utilities. Must not depend on other Tier 1 packages. The OpenCode handler reduces caller-supplied session events and does not depend on an OpenCode SDK. +- **Tier 1 — Handler packages** (`launchdarkly-ai-claude-agents`, `launchdarkly-ai-claude-messages`, `launchdarkly-ai-openai-agents`, `launchdarkly-ai-openai-messages`, `launchdarkly-ai-langchain-agents`, `launchdarkly-ai-langchain-messages`, `launchdarkly-ai-litellm-agents`, `launchdarkly-ai-litellm-messages`, `launchdarkly-ai-opencode-agents`, `launchdarkly-ai-vercel-agents`, `launchdarkly-ai-vercel-messages`, `launchdarkly-ai-bedrock-agents`, `launchdarkly-ai-bedrock-messages`, …): Each wraps a specific AI provider SDK. Depends on `launchdarkly-ai-server` for shared types and utilities. Must not depend on other Tier 1 packages. The OpenCode handler reduces caller-supplied session events and does not depend on an OpenCode SDK. - **Tier 2 — Consumer applications** (e.g. `main.py`, downstream projects): Imports from one or more handler packages and either `launchdarkly-ai-python` or `launchdarkly-ai-server`. Owns tool implementations and orchestration logic. No `launchdarkly-ai-*` package should ever depend on Tier 2 code. ### Rules diff --git a/README.md b/README.md index b6f18f4b..77d73f93 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ That call is the whole integration. Everything it does is configured in LaunchDa - Run agents and multi-step graphs, where each step can use a different provider - Score output quality with judges, including scoring that stays off the request path - See cost, latency, token usage, errors, and full conversations with no instrumentation code -- Keep the providers and frameworks you already run: OpenAI, Anthropic, LangChain, LiteLLM, or your own handler +- Keep the providers and frameworks you already run: OpenAI, Anthropic, Amazon Bedrock, LangChain, LiteLLM, or your own handler - [What you get](#what-you-get) - [How It Works](#how-it-works) @@ -89,6 +89,8 @@ Tier 0 — Core Client (launchdarkly-ai-server) | [`launchdarkly-ai-litellm-agents`](packages/litellm-agents/README.md) | `*` (any) | `agent` | OpenAI Agents SDK with LiteLLM models | | [`launchdarkly-ai-vercel-messages`](packages/vercel-messages/README.md) | `*` (any) | `messages` | Vercel AI SDK with AI Gateway routing | | [`launchdarkly-ai-vercel-agents`](packages/vercel-agents/README.md) | `*` (any) | `agent` | Native Vercel `ai.Agent` loop and graph adapter | +| [`launchdarkly-ai-bedrock-messages`](packages/bedrock-messages/README.md) | Bedrock | `messages` | Bedrock Runtime Converse with manual tool-call loop | +| [`launchdarkly-ai-bedrock-agents`](packages/bedrock-agents/README.md) | Bedrock | `agent` | Strands Agent backed by BedrockModel | ## Quick Start @@ -160,6 +162,8 @@ asyncio.run(main()) | `litellm_agents` | `launchdarkly-ai-litellm-agents` | `openai-agents[litellm]` | OpenAI Agents via LiteLLM | | `vercel_messages` | `launchdarkly-ai-vercel-messages` | `ai` | Vercel AI SDK stream runtime | | `vercel_agents` | `launchdarkly-ai-vercel-agents` | `ai` | Native Vercel `ai.Agent` | +| `bedrock_messages` | `launchdarkly-ai-bedrock-messages` | `aioboto3` | Bedrock Runtime Converse | +| `bedrock_agents` | `launchdarkly-ai-bedrock-agents` | `strands-agents` | Strands Agent + BedrockModel | --- @@ -261,7 +265,7 @@ async def main(): asyncio.run(main()) ``` -Provider packages also export conveniences (`claude_graph`, `openai_graph`, `langchain_graph`, `litellm_graph`) that pre-bind their handler. +Provider packages also export conveniences (`claude_graph`, `openai_graph`, `langchain_graph`, `litellm_graph`, `bedrock_graph`) that pre-bind their handler. --- @@ -672,7 +676,9 @@ python-ai-sdk/ │ ├── langchain-agents/ # launchdarkly-ai-langchain-agents │ ├── langchain-messages/ # launchdarkly-ai-langchain-messages │ ├── litellm-agents/ # launchdarkly-ai-litellm-agents -│ └── litellm-messages/ # launchdarkly-ai-litellm-messages +│ ├── litellm-messages/ # launchdarkly-ai-litellm-messages +│ ├── bedrock-agents/ # launchdarkly-ai-bedrock-agents +│ └── bedrock-messages/ # launchdarkly-ai-bedrock-messages ├── .env.example # Template — copy to .env and fill in your values └── agents.md # Architecture reference for AI agents and contributors ``` diff --git a/examples/bedrock_agents_example.py b/examples/bedrock_agents_example.py new file mode 100644 index 00000000..e84abb2d --- /dev/null +++ b/examples/bedrock_agents_example.py @@ -0,0 +1,21 @@ +"""Example: Amazon Bedrock with Strands Agent.""" + +from __future__ import annotations + +import json + +from examples.tools import get_preferences +from examples.utils import new_context, write_output +from launchdarkly_ai_bedrock_agents import bedrock_agents + + +async def run(key: str, user_input: str) -> None: + response = await bedrock_agents( + key, + user_input, + new_context(), + tool_handlers={"get-user-preferences": get_preferences}, + variables={"user_input": user_input}, + ) + print(json.dumps(response, indent=2, default=str)) + write_output(response) diff --git a/examples/bedrock_messages_example.py b/examples/bedrock_messages_example.py new file mode 100644 index 00000000..bd983211 --- /dev/null +++ b/examples/bedrock_messages_example.py @@ -0,0 +1,21 @@ +"""Example: Amazon Bedrock Runtime Converse.""" + +from __future__ import annotations + +import json + +from examples.tools import get_preferences +from examples.utils import new_context, write_output +from launchdarkly_ai_bedrock_messages import bedrock_messages + + +async def run(key: str, user_input: str) -> None: + response = await bedrock_messages( + key, + user_input, + new_context(), + tool_handlers={"get-user-preferences": get_preferences}, + variables={"user_input": user_input}, + ) + print(json.dumps(response, indent=2, default=str)) + write_output(response) diff --git a/examples/register.py b/examples/register.py index c5ff6a11..4e6de12d 100644 --- a/examples/register.py +++ b/examples/register.py @@ -15,6 +15,8 @@ search_ld_documentation, web_search, ) +from launchdarkly_ai_bedrock_agents import create_bedrock_agents_handler +from launchdarkly_ai_bedrock_messages import create_bedrock_messages_handler from launchdarkly_ai_claude_agents import ClaudeWebSearch, create_claude_agents_handler from launchdarkly_ai_claude_messages import create_claude_messages_handler from launchdarkly_ai_langchain_agents import create_langchain_agents_handler @@ -31,6 +33,8 @@ create_claude_messages_handler(), create_langchain_messages_handler(), create_langchain_agents_handler(), + create_bedrock_agents_handler(), + create_bedrock_messages_handler(), ], tools={ # LD documentation agent tools diff --git a/main.py b/main.py index f8295d6b..bdcc22d6 100644 --- a/main.py +++ b/main.py @@ -71,6 +71,8 @@ "vercel-agents": "examples.vercel_agents_example", "vercel-direct": "examples.vercel_direct", "vercel-messages": "examples.vercel_messages_example", + "bedrock-agents": "examples.bedrock_agents_example", + "bedrock-messages": "examples.bedrock_messages_example", "native-graph": "examples.native_graph", "native-graph-langchain": "examples.native_graph_langchain", "native-graph-vercel": "examples.native_graph_vercel", diff --git a/packages/bedrock-agents/CHANGELOG.md b/packages/bedrock-agents/CHANGELOG.md new file mode 100644 index 00000000..e4078b7a --- /dev/null +++ b/packages/bedrock-agents/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.0.1 + +- Initial Amazon Bedrock Strands agent handler and graph convenience wrapper. diff --git a/packages/bedrock-agents/README.md b/packages/bedrock-agents/README.md new file mode 100644 index 00000000..f125372d --- /dev/null +++ b/packages/bedrock-agents/README.md @@ -0,0 +1,12 @@ +# LaunchDarkly AI Bedrock Agents + +Amazon Bedrock integration using Strands `Agent` and `BedrockModel`. + +```python +from launchdarkly_ai_bedrock_agents import create_bedrock_agents_handler + +handler = create_bedrock_agents_handler(region="us-east-1") +``` + +The factory also accepts a boto3 `client` or `boto_session`. Injected clients remain owned by the +caller. `bedrock_graph()` pre-wires the handler into LaunchDarkly's generic graph runner. diff --git a/packages/bedrock-agents/agents.md b/packages/bedrock-agents/agents.md new file mode 100644 index 00000000..431dcb83 --- /dev/null +++ b/packages/bedrock-agents/agents.md @@ -0,0 +1,14 @@ +# Agent Guide — `launchdarkly-ai-bedrock-agents` + +This Tier 1 package routes `('Bedrock', 'agent')` configs through Strands `Agent` and +`BedrockModel`. It deliberately does not expose AgentCore or a Strands-native graph adapter. + +Important invariants: + +- `model.region` is an idempotent inference-profile prefix; factory `region` selects the endpoint. +- `model_options(config)` and documented model parameters configure `BedrockModel`; + `model.custom` is never consumed and generated `model_id` remains authoritative. +- A raw boto3 client is assigned to `model.client`; otherwise `boto_session` is forwarded. +- History uses Strands' structured message input rather than being flattened into the system prompt. +- `bedrock_graph()` pre-wires one Bedrock agent handler into the generic graph runner. +- Package telemetry uses the shared three-level span tree and folds Bedrock cache usage. diff --git a/packages/bedrock-agents/pyproject.toml b/packages/bedrock-agents/pyproject.toml new file mode 100644 index 00000000..55dee36c --- /dev/null +++ b/packages/bedrock-agents/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "launchdarkly-ai-bedrock-agents" +version = "0.0.1" +requires-python = ">=3.12" +dependencies = [ + "boto3>=1.40", + "launchdarkly-ai-server", + "opentelemetry-api>=1.25", + "strands-agents>=1.0", +] +description = "Amazon Bedrock Strands agent handler for LaunchDarkly AI SDK" +readme = "README.md" +license = "Apache-2.0" +authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] +keywords = ["launchdarkly", "ai", "aws", "bedrock", "strands"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", +] + +[project.urls] +Homepage = "https://github.com/launchdarkly/python-ai-sdk" +Repository = "https://github.com/launchdarkly/python-ai-sdk" +"Bug Tracker" = "https://github.com/launchdarkly/python-ai-sdk/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/launchdarkly_ai_bedrock_agents"] diff --git a/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/__init__.py b/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/__init__.py new file mode 100644 index 00000000..4a1e2dc3 --- /dev/null +++ b/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/__init__.py @@ -0,0 +1,11 @@ +"""LaunchDarkly AI SDK integration for Amazon Bedrock Strands agents.""" + +from launchdarkly_ai_server import register_ai_sdk_package + +from ._version import PACKAGE_NAME, __version__ +from .graph import bedrock_graph +from .handler import bedrock_agents, create_bedrock_agents_handler + +__all__ = ["bedrock_agents", "bedrock_graph", "create_bedrock_agents_handler"] + +register_ai_sdk_package(PACKAGE_NAME, __version__) diff --git a/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/_version.py b/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/_version.py new file mode 100644 index 00000000..28370068 --- /dev/null +++ b/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/_version.py @@ -0,0 +1,4 @@ +"""This package's identity, as sent with ``$ld:ai:sdk:info`` and ``$ld:ai:sdk:usage``.""" + +PACKAGE_NAME = "launchdarkly-ai-bedrock-agents" +__version__ = "0.0.1" # x-release-please-version diff --git a/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/graph.py b/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/graph.py new file mode 100644 index 00000000..56acc3e7 --- /dev/null +++ b/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/graph.py @@ -0,0 +1,17 @@ +"""Graph convenience wrapper for Bedrock agents.""" + +from __future__ import annotations + +from typing import Any + +from launchdarkly_ai_server import _graph, report_usage + +from ._version import PACKAGE_NAME, __version__ +from .handler import _create_bedrock_agents_handler + + +def bedrock_graph(key: str, **options: Any) -> Any: + """Create a generic graph with exactly one Bedrock agent handler.""" + report_usage("bedrock-agents.bedrockGraph", PACKAGE_NAME, __version__) + options.pop("handlers", None) + return _graph(key, handlers=[_create_bedrock_agents_handler()], **options) diff --git a/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/handler.py b/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/handler.py new file mode 100644 index 00000000..662b54df --- /dev/null +++ b/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/handler.py @@ -0,0 +1,634 @@ +"""Amazon Bedrock agent handler built on Strands.""" + +from __future__ import annotations + +import asyncio +import base64 +import inspect +import json +import os +from collections.abc import AsyncGenerator, Callable +from contextlib import asynccontextmanager +from typing import Any + +from launchdarkly_ai_server import ( + AiConfigRep, + LDContext, + ProviderHandler, + SpanMessage, + SpanMessagePart, + SpanUsage, + _config, + _create_handler, + end_span_once, + end_unfinished_spans, + parse_template, + report_usage, + set_input_content_attributes, + set_output_content_attributes, + set_tool_call_content_attributes, + text_message, +) +from launchdarkly_ai_server.lifecycle import effective_capture_content + +from ._version import PACKAGE_NAME, __version__ +from .spans import ( + fail_span, + finish_model_span, + finish_root_span, + mark_ok, + model_id, + parent_context_of, + span_messages, + start_model_span, + start_root_span, + start_tool_span, + succeed_span, + usage_of, +) + +ModelOptions = Callable[[AiConfigRep], dict[str, Any]] +_BEARER_ENV_LOCK = asyncio.Lock() + + +@asynccontextmanager +async def _bearer_scope(token: str | None) -> AsyncGenerator[None, None]: + if not token: + yield + return + async with _BEARER_ENV_LOCK: + previous = os.environ.get("AWS_BEARER_TOKEN_BEDROCK") + os.environ["AWS_BEARER_TOKEN_BEDROCK"] = token + try: + yield + finally: + if previous is None: + os.environ.pop("AWS_BEARER_TOKEN_BEDROCK", None) + else: + os.environ["AWS_BEARER_TOKEN_BEDROCK"] = previous + + +def _system_prompt(cfg: AiConfigRep, variables: dict[str, Any]) -> str | None: + system: str | None + if cfg.get("instructions"): + system = parse_template(str(cfg["instructions"]), variables) + else: + systems = [ + str(message.get("content", "")) + for message in cfg.get("messages") or [] + if message.get("role") == "system" + ] + system = parse_template("\n".join(systems), variables) if systems else None + if cfg.get("outputFormat"): + instruction = "Respond with valid JSON matching this schema:\n" + json.dumps( + cfg["outputFormat"], separators=(",", ":") + ) + system = f"{system}\n\n{instruction}" if system else instruction + return system + + +def _native_content(content: Any) -> list[dict[str, Any]]: + if isinstance(content, str): + return [{"text": content}] + if not isinstance(content, list): + return [{"text": str(content or "")}] + result: list[dict[str, Any]] = [] + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text": + result.append({"text": str(block.get("text", ""))}) + elif block.get("type") == "image": + source = block.get("source") or {} + image_format = ( + str(source.get("media_type", "image/png")) + .rsplit("/", 1)[-1] + .replace("jpg", "jpeg") + ) + if source.get("type") == "base64": + result.append( + { + "image": { + "format": image_format, + "source": { + "bytes": base64.b64decode(str(source.get("data", ""))) + }, + } + } + ) + elif source.get("type") == "s3": + result.append( + { + "image": { + "format": image_format, + "source": {"s3Location": {"uri": source.get("url", "")}}, + } + } + ) + elif source.get("type") == "url": + raise ValueError( + "Bedrock does not accept remote image URLs; use base64 or s3" + ) + return result + + +def _prompt( + cfg: AiConfigRep, + user_input: str | None, + variables: dict[str, Any], + history: list[dict[str, Any]] | None, +) -> str | list[dict[str, Any]]: + messages: list[dict[str, Any]] = [] + if not cfg.get("instructions"): + for message in cfg.get("messages") or []: + if message.get("role") in ("user", "assistant"): + messages.append( + { + "role": message["role"], + "content": [ + { + "text": parse_template( + str(message.get("content", "")), variables + ) + } + ], + } + ) + for message in history or []: + if message.get("role") in ("user", "assistant"): + messages.append( + { + "role": message["role"], + "content": _native_content(message.get("content", "")), + } + ) + if not messages: + return user_input or "" + if user_input or messages[-1]["role"] != "user": + messages.append({"role": "user", "content": [{"text": user_input or ""}]}) + return messages + + +def _tools(cfg: AiConfigRep, handlers: dict[str, Any]) -> list[Any]: + from strands import tool + + result = [] + for name, definition in (cfg.get("tools") or {}).items(): + handler = handlers.get(name) + if not callable(handler): + continue + + async def execute(_handler: Any = handler, **kwargs: Any) -> Any: + value = _handler(kwargs) + return await value if inspect.isawaitable(value) else value + + result.append( + tool( + name=name, + description=definition.get("description", ""), + inputSchema=definition.get("parameters") or {}, + )(execute) + ) + return result + + +def _model_config(cfg: AiConfigRep) -> dict[str, Any]: + parameters = cfg.get("model", {}).get("parameters") or {} + aliases = { + "max_tokens": "max_tokens", + "maxTokens": "max_tokens", + "temperature": "temperature", + "top_p": "top_p", + "topP": "top_p", + "stop_sequences": "stop_sequences", + "stopSequences": "stop_sequences", + } + return { + target: parameters[source] + for source, target in aliases.items() + if source in parameters + } + + +def _result_usage(result: Any) -> dict[str, Any]: + metrics = getattr(result, "metrics", None) + usage = getattr(metrics, "accumulated_usage", None) + return dict(usage) if isinstance(usage, dict) else {} + + +def _result_text(result: Any) -> str: + message = getattr(result, "message", None) + if not isinstance(message, dict): + return str(result or "") + content = message.get("content") or [] + return "".join( + str(block.get("text", "")) for block in content if isinstance(block, dict) + ) + + +class _RunSpans: + def __init__(self, parent: Any, capture_content: bool) -> None: + self.parent = parent + self.capture_content = capture_content + self.open_model_span: Any = None + self.open_tool_spans: dict[str, Any] = {} + self.span_usage = SpanUsage() + self.reported = False + + def add_usage(self, usage: Any) -> SpanUsage: + _, span = usage_of(usage) + if isinstance(usage, dict): + self.reported = True + self.span_usage.input += span.input + self.span_usage.output += span.output + self.span_usage.cache_read += span.cache_read + self.span_usage.cache_creation += span.cache_creation + return span + + def register_hooks(self, registry: Any, **_kwargs: Any) -> None: + from strands.hooks.events import AfterToolCallEvent, BeforeToolCallEvent + + registry.add_callback(BeforeToolCallEvent, self.before) + registry.add_callback(AfterToolCallEvent, self.after) + + def before(self, event: Any) -> None: + use = event.tool_use + call_id = str(use.get("toolUseId", "")) + span = start_tool_span(str(use.get("name", "")), call_id, self.parent) + self.open_tool_spans[call_id] = span + set_tool_call_content_attributes( + span, self.capture_content, arguments=use.get("input") + ) + + def after(self, event: Any) -> None: + call_id = str(event.tool_use.get("toolUseId", "")) + span = self.open_tool_spans.get(call_id) + if event.exception is not None: + fail_span(span, event.exception) + self.open_tool_spans.pop(call_id, None) + return + try: + set_tool_call_content_attributes( + span, self.capture_content, result=event.result + ) + succeed_span(span) + self.open_tool_spans.pop(call_id, None) + except Exception as exc: + fail_span(span, exc) + self.open_tool_spans.pop(call_id, None) + raise + + def close_open_spans(self, error: BaseException) -> None: + """Fail child spans still open when Strands raises.""" + for span in self.open_tool_spans.values(): + fail_span(span, error) + self.open_tool_spans.clear() + if self.open_model_span is not None: + fail_span(self.open_model_span, error) + self.open_model_span = None + + def abandon_open_spans(self, ended: set[int], cancelled: bool = False) -> None: + """End interrupted stream children without marking them as errors.""" + for span in self.open_tool_spans.values(): + end_span_once(span, ended, abandoned=True, cancelled=cancelled) + self.open_tool_spans.clear() + if self.open_model_span is not None: + end_span_once( + self.open_model_span, + ended, + abandoned=True, + cancelled=cancelled, + ) + self.open_model_span = None + + def cancel_open_spans(self) -> None: + """End blocking-run children skipped by CancelledError.""" + end_unfinished_spans( + *self.open_tool_spans.values(), + self.open_model_span, + ) + self.open_tool_spans.clear() + self.open_model_span = None + + +def _instrument_model( + model: Any, + cfg: AiConfigRep, + spans: _RunSpans, +) -> None: + original = model.stream + + async def stream(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, None]: + messages = args[0] if args else kwargs.get("messages") or [] + system_prompt = args[2] if len(args) > 2 else kwargs.get("system_prompt") + span = start_model_span(cfg, spans.parent) + spans.open_model_span = span + usage_data: dict[str, Any] | None = None + reason: str | None = None + text = "" + content: list[dict[str, Any]] = [] + try: + if spans.capture_content: + set_input_content_attributes( + span, + True, + system_instructions=system_prompt, + messages=( + span_messages(messages) if isinstance(messages, list) else [] + ), + ) + async for event in original(*args, **kwargs): + if isinstance(event, dict): + if "metadata" in event: + usage_data = event["metadata"].get("usage") or {} + if "messageStop" in event: + reason = event["messageStop"].get("stopReason") + delta = (event.get("contentBlockDelta") or {}).get("delta") or {} + if "text" in delta: + text += str(delta["text"]) + start = (event.get("contentBlockStart") or {}).get("start") or {} + if "toolUse" in start: + content.append({"toolUse": start["toolUse"]}) + yield event + if text: + content.insert(0, {"text": text}) + turn_usage = spans.add_usage(usage_data) + if spans.capture_content: + set_output_content_attributes( + span, + True, + [ + SpanMessage( + role="assistant", + parts=span_messages( + [{"role": "assistant", "content": content}] + )[0].parts + if content + else [SpanMessagePart(type="text", content=text)], + finish_reason=reason, + ) + ], + ) + finish_model_span(span, cfg, turn_usage, reason) + spans.open_model_span = None + except Exception as exc: + fail_span(span, exc) + spans.open_model_span = None + raise + + model.stream = stream + + +def _build_agent( + cfg: AiConfigRep, + handlers: dict[str, Any], + variables: dict[str, Any], + *, + client: Any, + boto_session: Any, + region: str | None, + model_options: ModelOptions | None, + parent: Any, + capture_content: bool, +) -> tuple[Any, str | None, _RunSpans]: + from strands import Agent + from strands.models import BedrockModel + + options = dict(model_options(cfg) or {}) if model_options else {} + options.update(_model_config(cfg)) + options["model_id"] = model_id(cfg) + if client is None: + if boto_session is not None: + options["boto_session"] = boto_session + if region: + options["region_name"] = region + + model = BedrockModel(**options) + if client is not None: + model.client = client + run_spans = _RunSpans(parent, capture_content) + _instrument_model(model, cfg, run_spans) + system = _system_prompt(cfg, variables) + agent = Agent( + model=model, + system_prompt=system, + tools=_tools(cfg, handlers), + hooks=[run_spans], + callback_handler=None, + ) + return agent, system, run_spans + + +def create_bedrock_agents_handler( + *, + client: Any = None, + boto_session: Any = None, + api_key: str | None = None, + region: str | None = None, + model_options: ModelOptions | None = None, + capture_content: bool | None = None, +) -> ProviderHandler: + """Create a Strands Agent backed by BedrockModel.""" + report_usage("bedrock-agents.createBedrockAgentsHandler", PACKAGE_NAME, __version__) + return _create_bedrock_agents_handler( + client=client, + boto_session=boto_session, + api_key=api_key, + region=region, + model_options=model_options, + capture_content=capture_content, + ) + + +def _create_bedrock_agents_handler( + *, + client: Any = None, + boto_session: Any = None, + api_key: str | None = None, + region: str | None = None, + model_options: ModelOptions | None = None, + capture_content: bool | None = None, +) -> ProviderHandler: + """Non-reporting :func:`create_bedrock_agents_handler`, used by this package's wrappers.""" + + async def invoke( + cfg: AiConfigRep, + user_input: str | None = None, + tool_handlers: dict[str, Any] | None = None, + variables: dict[str, Any] | None = None, + history: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + capture = effective_capture_content(capture_content) + values = variables or {} + prompt = _prompt(cfg, user_input, values, history) + bearer = _bearer_scope(api_key if client is None else None) + await bearer.__aenter__() + root = start_root_span(cfg, values) + open_root: Any = root + parent = parent_context_of(root) + run_spans: _RunSpans | None = None + try: + agent, system, run_spans = _build_agent( + cfg, + tool_handlers or {}, + values, + client=client, + boto_session=boto_session, + region=region, + model_options=model_options, + parent=parent, + capture_content=capture, + ) + set_input_content_attributes( + root, + capture, + system_instructions=system, + messages=[text_message("user", prompt)] + if isinstance(prompt, str) + else span_messages(prompt), + ) + result = await agent.invoke_async(prompt) + output = _result_text(result) + result_usage = _result_usage(result) + raw, span_usage = usage_of(result_usage) + if not result_usage and run_spans.reported: + span_usage = run_spans.span_usage + set_output_content_attributes( + root, capture, [text_message("assistant", output)] + ) + finish_root_span(root, cfg, span_usage) + succeed_span(root) + open_root = None + return {"output": output, "usage": raw} + except Exception as exc: + if run_spans is not None: + run_spans.close_open_spans(exc) + if run_spans.reported: + finish_root_span(root, cfg, run_spans.span_usage) + fail_span(root, exc) + open_root = None + raise + finally: + if run_spans is not None: + run_spans.cancel_open_spans() + end_unfinished_spans(open_root) + await bearer.__aexit__(None, None, None) + + async def stream( + cfg: AiConfigRep, + user_input: str | None = None, + tool_handlers: dict[str, Any] | None = None, + variables: dict[str, Any] | None = None, + history: list[dict[str, Any]] | None = None, + ) -> AsyncGenerator[dict[str, Any], None]: + capture = effective_capture_content(capture_content) + values = variables or {} + prompt = _prompt(cfg, user_input, values, history) + bearer = _bearer_scope(api_key if client is None else None) + await bearer.__aenter__() + root = start_root_span(cfg, values) + parent = parent_context_of(root) + ended: set[int] = set() + run_spans: _RunSpans | None = None + cancelled = False + try: + agent, system, run_spans = _build_agent( + cfg, + tool_handlers or {}, + values, + client=client, + boto_session=boto_session, + region=region, + model_options=model_options, + parent=parent, + capture_content=capture, + ) + set_input_content_attributes( + root, + capture, + system_instructions=system, + messages=[text_message("user", prompt)] + if isinstance(prompt, str) + else span_messages(prompt), + ) + output = "" + final_result: Any = None + async for event in agent.stream_async(prompt): + if isinstance(event, dict) and isinstance(event.get("data"), str): + text = event["data"] + output += text + yield {"type": "chunk", "text": text} + if isinstance(event, dict) and event.get("result") is not None: + final_result = event["result"] + if final_result is not None: + output = _result_text(final_result) or output + raw, span_usage = usage_of( + _result_usage(final_result) if final_result is not None else {} + ) + if ( + (final_result is None or not _result_usage(final_result)) + and run_spans is not None + and run_spans.reported + ): + span_usage = run_spans.span_usage + set_output_content_attributes( + root, capture, [text_message("assistant", output)] + ) + finish_root_span(root, cfg, span_usage) + mark_ok(root) + end_span_once(root, ended) + yield {"type": "done", "output": output, "usage": raw} + except asyncio.CancelledError: + cancelled = True + raise + except Exception as exc: + if run_spans is not None: + run_spans.close_open_spans(exc) + if run_spans.reported: + finish_root_span(root, cfg, run_spans.span_usage) + fail_span(root, exc, ended) + raise + finally: + if run_spans is not None and root is not None and id(root) not in ended: + if run_spans.reported: + finish_root_span(root, cfg, run_spans.span_usage) + run_spans.abandon_open_spans(ended, cancelled=cancelled) + end_span_once( + root, + ended, + abandoned=True, + cancelled=cancelled, + ) + await bearer.__aexit__(None, None, None) + + return _create_handler( + ("Bedrock", "agent"), + invoke, + stream, + capture_content=capture_content, + ) + + +def bedrock_agents( + config_key: str, + user_input: str, + context: LDContext, + **kwargs: Any, +) -> Any: + """Invoke a LaunchDarkly AI config using a Strands Bedrock agent.""" + report_usage("bedrock-agents.bedrockAgents", PACKAGE_NAME, __version__) + variables = kwargs.pop("variables", None) + factory_keys = ( + "client", + "boto_session", + "api_key", + "region", + "model_options", + "capture_content", + ) + factory = {key: kwargs.pop(key) for key in factory_keys if key in kwargs} + return _config( + key=config_key, + handler=_create_bedrock_agents_handler(**factory), + **kwargs, + ).invoke(user_input, context, variables=variables) diff --git a/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/spans.py b/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/spans.py new file mode 100644 index 00000000..50f35efe --- /dev/null +++ b/packages/bedrock-agents/src/launchdarkly_ai_bedrock_agents/spans.py @@ -0,0 +1,202 @@ +"""OpenTelemetry span helpers for Bedrock Strands agents.""" + +from __future__ import annotations + +from typing import Any + +from launchdarkly_ai_server import ( + AiConfigRep, + SpanMessage, + SpanMessagePart, + SpanUsage, + number_or_zero, + set_ld_span_attributes, + set_model_identity_attributes, + set_usage_span_attributes, +) + +try: + from opentelemetry import trace + from opentelemetry.trace import StatusCode + + _HAS_OTEL = True +except ImportError: # pragma: no cover + _HAS_OTEL = False + +TRACER_NAME = "@launchdarkly/ai-bedrock-agents" +PROVIDER = "aws.bedrock" + + +def model_id(config: AiConfigRep) -> str: + model = config.get("model", {}) + name = str(model.get("name", "")) + prefix = str(model.get("region") or "") + return name if not prefix or name.startswith(f"{prefix}.") else f"{prefix}.{name}" + + +def model_name(config: AiConfigRep) -> str: + """Shared span surface name for Bedrock's resolved model ID.""" + return model_id(config) + + +def start_root_span(config: AiConfigRep, variables: dict[str, Any]) -> Any: + if not _HAS_OTEL: + return None + span = trace.get_tracer(TRACER_NAME).start_span("invoke_agent") + span.set_attribute("gen_ai.operation.name", "invoke_agent") + set_model_identity_attributes(span, PROVIDER, model_id(config)) + set_ld_span_attributes(span, variables) + return span + + +def parent_context_of(span: Any) -> Any: + return trace.set_span_in_context(span) if _HAS_OTEL and span is not None else None + + +def start_model_span(config: AiConfigRep, parent: Any) -> Any: + if not _HAS_OTEL: + return None + name = model_id(config) + span = trace.get_tracer(TRACER_NAME).start_span(f"chat {name}", context=parent) + span.set_attribute("gen_ai.operation.name", "chat") + set_model_identity_attributes(span, PROVIDER, name) + return span + + +def start_tool_span(name: str, call_id: str, parent: Any) -> Any: + if not _HAS_OTEL: + return None + span = trace.get_tracer(TRACER_NAME).start_span( + f"execute_tool {name}", context=parent + ) + span.set_attribute("gen_ai.operation.name", "execute_tool") + span.set_attribute("gen_ai.tool.name", name) + span.set_attribute("gen_ai.tool.call.id", call_id) + return span + + +def usage_of(value: Any) -> tuple[dict[str, int], SpanUsage]: + value = value if isinstance(value, dict) else {} + raw = { + "input_tokens": number_or_zero( + value.get("inputTokens", value.get("input_tokens")) + ), + "output_tokens": number_or_zero( + value.get("outputTokens", value.get("output_tokens")) + ), + "total_tokens": number_or_zero( + value.get("totalTokens", value.get("total_tokens")) + ), + } + read = number_or_zero( + value.get("cacheReadInputTokens", value.get("cache_read_input_tokens")) + ) + write = number_or_zero( + value.get("cacheWriteInputTokens", value.get("cache_write_input_tokens")) + ) + if "cacheReadInputTokens" in value or "cache_read_input_tokens" in value: + raw["cache_read_input_tokens"] = read + if "cacheWriteInputTokens" in value or "cache_write_input_tokens" in value: + raw["cache_creation_input_tokens"] = write + return raw, SpanUsage( + input=raw["input_tokens"] + read + write, + output=raw["output_tokens"], + cache_read=read, + cache_creation=write, + ) + + +def finish_reason(reason: str) -> str: + return { + "end_turn": "stop", + "stop_sequence": "stop", + "max_tokens": "length", + "limit_output_tokens": "length", + "tool_use": "tool_calls", + "refusal": "content_filter", + "content_filtered": "content_filter", + }.get(reason.lower(), reason) + + +def finish_model_span( + span: Any, config: AiConfigRep, usage: SpanUsage, reason: str | None +) -> None: + if span is None: + return + span.set_attribute("gen_ai.response.model", model_id(config)) + if reason: + span.set_attribute("gen_ai.response.finish_reasons", [finish_reason(reason)]) + set_usage_span_attributes(span, usage) + span.set_status(StatusCode.OK) + span.end() + + +def finish_root_span(span: Any, config: AiConfigRep, usage: SpanUsage) -> None: + if span is not None: + span.set_attribute("gen_ai.response.model", model_id(config)) + set_usage_span_attributes(span, usage) + + +def succeed_span(span: Any) -> None: + if span is not None: + span.set_status(StatusCode.OK) + span.end() + + +def mark_ok(span: Any) -> None: + if span is not None: + span.set_status(StatusCode.OK) + + +def fail_span(span: Any, error: BaseException, ended: set[int] | None = None) -> None: + if span is None: + return + span.record_exception(error) + span.set_status(StatusCode.ERROR, str(error)) + if ended is None: + span.end() + else: + from launchdarkly_ai_server import end_span_once + + end_span_once(span, ended) + + +def content_parts(content: Any) -> list[SpanMessagePart]: + if not isinstance(content, list): + return [] + parts: list[SpanMessagePart] = [] + for block in content: + if not isinstance(block, dict): + continue + if "text" in block: + parts.append(SpanMessagePart(type="text", content=str(block["text"]))) + elif "toolUse" in block: + use = block["toolUse"] + parts.append( + SpanMessagePart( + type="tool_call", + id=use.get("toolUseId"), + name=str(use.get("name", "")), + arguments=use.get("input"), + ) + ) + elif "toolResult" in block: + result = block["toolResult"] + parts.append( + SpanMessagePart( + type="tool_call_response", + id=result.get("toolUseId"), + result=result.get("content"), + ) + ) + return parts + + +def span_messages(messages: list[dict[str, Any]]) -> list[SpanMessage]: + return [ + SpanMessage( + role=str(message.get("role", "")), + parts=content_parts(message.get("content")), + ) + for message in messages + ] diff --git a/packages/bedrock-agents/tests/test_graph.py b/packages/bedrock-agents/tests/test_graph.py new file mode 100644 index 00000000..f3f7ffa2 --- /dev/null +++ b/packages/bedrock-agents/tests/test_graph.py @@ -0,0 +1,44 @@ +"""Contract tests for the Bedrock Strands graph convenience wrapper.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from launchdarkly_ai_bedrock_agents.graph import bedrock_graph + + +class TestBedrockGraph: + def test_passes_key_and_options_to_graph(self) -> None: + tools = {"lookup": MagicMock()} + registry = MagicMock() + + with patch("launchdarkly_ai_bedrock_agents.graph._graph") as graph_mock: + expected = MagicMock() + graph_mock.return_value = expected + actual = bedrock_graph( + "bedrock-graph", tool_handlers=tools, registry=registry + ) + + assert actual is expected + graph_mock.assert_called_once() + assert graph_mock.call_args.args[0] == "bedrock-graph" + assert graph_mock.call_args.kwargs["tool_handlers"] is tools + assert graph_mock.call_args.kwargs["registry"] is registry + + def test_prebinds_exactly_one_bedrock_agent_handler(self) -> None: + with patch("launchdarkly_ai_bedrock_agents.graph._graph") as graph_mock: + bedrock_graph("bedrock-graph") + + handlers = graph_mock.call_args.kwargs["handlers"] + assert len(handlers) == 1 + assert handlers[0].provides_for == ("Bedrock", "agent") + + def test_caller_cannot_override_prebound_handler(self) -> None: + wrong_handler = MagicMock() + + with patch("launchdarkly_ai_bedrock_agents.graph._graph") as graph_mock: + bedrock_graph("bedrock-graph", handlers=[wrong_handler]) + + handlers = graph_mock.call_args.kwargs["handlers"] + assert len(handlers) == 1 + assert handlers != [wrong_handler] diff --git a/packages/bedrock-agents/tests/test_handler.py b/packages/bedrock-agents/tests/test_handler.py new file mode 100644 index 00000000..e4c9ac42 --- /dev/null +++ b/packages/bedrock-agents/tests/test_handler.py @@ -0,0 +1,481 @@ +"""Contract tests for the Bedrock Strands agents handler.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from launchdarkly_ai_bedrock_agents import create_bedrock_agents_handler +from launchdarkly_ai_bedrock_agents import handler as handler_module + +BASE_CONFIG: dict[str, Any] = { + "model": { + "name": "anthropic.claude-sonnet-4-5", + "region": "us", + "parameters": {"max_tokens": 512, "temperature": 0.1}, + "custom": {"mustNeverLeak": "sentinel"}, + }, + "provider": {"name": "Bedrock"}, + "instructions": "Help {{name}}.", +} + + +def agent_result(text: str = "done") -> SimpleNamespace: + return SimpleNamespace( + message={"role": "assistant", "content": [{"text": text}]}, + metrics=SimpleNamespace( + accumulated_usage={ + "inputTokens": 11, + "outputTokens": 4, + "totalTokens": 15, + } + ), + ) + + +class TestFactory: + def test_routes_as_bedrock_agent(self) -> None: + with ( + patch("strands.models.BedrockModel"), + patch("strands.Agent"), + ): + handler = create_bedrock_agents_handler() + + assert handler.provides_for == ("Bedrock", "agent") + + +class TestStrandsConstruction: + async def test_constructs_bedrock_model_and_agent(self) -> None: + model = MagicMock() + agent = MagicMock() + agent.invoke_async = AsyncMock(return_value=agent_result()) + + with ( + patch("strands.models.BedrockModel", return_value=model) as model_cls, + patch("strands.Agent", return_value=agent) as agent_cls, + ): + result = await create_bedrock_agents_handler()( + BASE_CONFIG, "hello", {}, {"name": "Ada"} + ) + + model_options = model_cls.call_args.kwargs + assert model_options["model_id"] == "us.anthropic.claude-sonnet-4-5" + assert model_options["max_tokens"] == 512 + assert model_options["temperature"] == 0.1 + assert "mustNeverLeak" not in repr(model_options) + agent_cls.assert_called_once() + assert agent_cls.call_args.kwargs["model"] is model + assert agent_cls.call_args.kwargs["system_prompt"] == "Help Ada." + agent.invoke_async.assert_awaited_once_with("hello") + assert result["output"] == "done" + + async def test_does_not_double_prepend_matching_region_prefix(self) -> None: + agent = MagicMock() + agent.invoke_async = AsyncMock(return_value=agent_result()) + config = { + **BASE_CONFIG, + "model": { + **BASE_CONFIG["model"], + "region": "us", + "name": "us.anthropic.claude-sonnet-4-5", + }, + } + + with ( + patch("strands.models.BedrockModel") as model_cls, + patch("strands.Agent", return_value=agent), + ): + await create_bedrock_agents_handler()(config, "hello") + + model_id = model_cls.call_args.kwargs["model_id"] + assert model_id == "us.anthropic.claude-sonnet-4-5" + assert model_id != "us.us.anthropic.claude-sonnet-4-5" + + async def test_model_options_callback_exposes_strands_options(self) -> None: + agent = MagicMock() + agent.invoke_async = AsyncMock(return_value=agent_result()) + callback = MagicMock( + return_value={ + "guardrail_id": "guardrail", + "guardrail_version": "1", + "additional_request_fields": {"thinking": {"type": "enabled"}}, + } + ) + + with ( + patch("strands.models.BedrockModel") as model_cls, + patch("strands.Agent", return_value=agent), + ): + await create_bedrock_agents_handler(model_options=callback)( + BASE_CONFIG, "hello" + ) + + callback.assert_called_once_with(BASE_CONFIG) + options = model_cls.call_args.kwargs + assert options["model_id"] == "us.anthropic.claude-sonnet-4-5" + assert options["guardrail_id"] == "guardrail" + assert options["additional_request_fields"]["thinking"]["type"] == "enabled" + + async def test_model_id_cannot_be_overridden_by_options_callback(self) -> None: + agent = MagicMock() + agent.invoke_async = AsyncMock(return_value=agent_result()) + + with ( + patch("strands.models.BedrockModel") as model_cls, + patch("strands.Agent", return_value=agent), + ): + await create_bedrock_agents_handler( + model_options=lambda _config: {"model_id": "wrong"} + )(BASE_CONFIG, "hello") + + assert ( + model_cls.call_args.kwargs["model_id"] == "us.anthropic.claude-sonnet-4-5" + ) + + +class TestClientEscapeHatch: + async def test_preconstructed_boto3_client_is_installed_on_model(self) -> None: + client = MagicMock(name="bedrock-runtime-client") + model = MagicMock() + internally_created_client = model.client + agent = MagicMock() + agent.invoke_async = AsyncMock(return_value=agent_result()) + + with ( + patch("strands.models.BedrockModel", return_value=model) as model_cls, + patch("strands.Agent", return_value=agent), + ): + await create_bedrock_agents_handler( + client=client, + api_key="must-not-reconfigure-client", + region="us-east-1", + )(BASE_CONFIG, "hello") + + assert model.client is client + assert model.client is not internally_created_client + assert "api_key" not in model_cls.call_args.kwargs + assert "region_name" not in model_cls.call_args.kwargs + + async def test_boto_session_is_forwarded_without_raw_client(self) -> None: + session = MagicMock(name="boto-session") + agent = MagicMock() + agent.invoke_async = AsyncMock(return_value=agent_result()) + + with ( + patch("strands.models.BedrockModel") as model_cls, + patch("strands.Agent", return_value=agent), + ): + await create_bedrock_agents_handler(boto_session=session)( + BASE_CONFIG, "hello" + ) + + assert model_cls.call_args.kwargs["boto_session"] is session + + +class TestStreamingLifecycle: + async def test_abandoned_stream_closes_model_tool_and_root_spans( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + spans: list[_RecordingSpan] = [] + monkeypatch.setattr( + handler_module, + "start_root_span", + lambda *_args: _new_span(spans, "root"), + ) + monkeypatch.setattr( + handler_module, + "start_model_span", + lambda *_args: _new_span(spans, "model"), + ) + monkeypatch.setattr( + handler_module, + "start_tool_span", + lambda *_args: _new_span(spans, "tool"), + ) + monkeypatch.setattr(handler_module, "parent_context_of", lambda _span: None) + + class FakeModel: + client = MagicMock() + + async def stream(self, *_args: Any, **_kwargs: Any): + yield {"metadata": {"usage": {"inputTokens": 1, "outputTokens": 1}}} + await asyncio.Event().wait() + + class FakeAgent: + def __init__(self, **kwargs: Any) -> None: + self.model = kwargs["model"] + self.hook = kwargs["hooks"][0] + + async def stream_async(self, _prompt: Any): + async for _event in self.model.stream([], None, None): + self.hook.before( + SimpleNamespace( + tool_use={ + "toolUseId": "tool-1", + "name": "lookup", + "input": {}, + } + ) + ) + yield {"data": "first"} + await asyncio.Event().wait() + + with ( + patch("strands.models.BedrockModel", return_value=FakeModel()), + patch("strands.Agent", FakeAgent), + ): + provider_stream = await create_bedrock_agents_handler().stream( + BASE_CONFIG, "hello" + ) + assert await anext(provider_stream) == {"type": "chunk", "text": "first"} + await provider_stream.aclose() + + assert [span.name for span in spans] == ["root", "model", "tool"] + assert all(not span.is_recording() for span in spans) + assert all( + span.attributes["launchdarkly.stream.abandoned"] is True for span in spans + ) + + async def test_cancelled_stream_marks_model_tool_and_root_cancelled( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + spans: list[_RecordingSpan] = [] + monkeypatch.setattr( + handler_module, + "start_root_span", + lambda *_args: _new_span(spans, "root"), + ) + monkeypatch.setattr( + handler_module, + "start_model_span", + lambda *_args: _new_span(spans, "model"), + ) + monkeypatch.setattr( + handler_module, + "start_tool_span", + lambda *_args: _new_span(spans, "tool"), + ) + monkeypatch.setattr(handler_module, "parent_context_of", lambda _span: None) + entered = asyncio.Event() + + class FakeModel: + client = MagicMock() + + async def stream(self, *_args: Any, **_kwargs: Any): + yield {"metadata": {"usage": {"inputTokens": 1, "outputTokens": 1}}} + await asyncio.Event().wait() + + class FakeAgent: + def __init__(self, **kwargs: Any) -> None: + self.model = kwargs["model"] + self.hook = kwargs["hooks"][0] + + async def stream_async(self, _prompt: Any): + async for _event in self.model.stream([], None, None): + self.hook.before( + SimpleNamespace( + tool_use={ + "toolUseId": "tool-1", + "name": "lookup", + "input": {}, + } + ) + ) + entered.set() + await asyncio.Event().wait() + yield {"data": "unreachable"} + + with ( + patch("strands.models.BedrockModel", return_value=FakeModel()), + patch("strands.Agent", FakeAgent), + ): + provider_stream = await create_bedrock_agents_handler().stream( + BASE_CONFIG, "hello" + ) + pending = asyncio.create_task(anext(provider_stream)) + await entered.wait() + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + + assert [span.name for span in spans] == ["root", "model", "tool"] + assert all(not span.is_recording() for span in spans) + assert all( + span.attributes["launchdarkly.run.cancelled"] is True for span in spans + ) + + +class RecordedSpan: + def __init__(self, name: str, context: Any = None) -> None: + self.name = name + self.context = context + self.attributes: dict[str, Any] = {} + self.events: list[tuple[str, dict[str, Any]]] = [] + self.ended = 0 + + def is_recording(self) -> bool: + return self.ended == 0 + + def set_attribute(self, key: str, value: Any) -> None: + self.attributes[key] = value + + def add_event(self, name: str, attributes: dict[str, Any] | None = None) -> None: + self.events.append((name, attributes or {})) + + def set_status(self, *_args: Any) -> None: + pass + + def record_exception(self, _error: BaseException) -> None: + pass + + def end(self) -> None: + self.ended += 1 + + +class SpanRecorder: + def __init__(self) -> None: + self.spans: list[RecordedSpan] = [] + + def get_tracer(self, name: str) -> SpanRecorder: + return self + + def start_span(self, name: str, context: Any = None) -> RecordedSpan: + span = RecordedSpan(name, context) + self.spans.append(span) + return span + + def set_span_in_context(self, span: RecordedSpan) -> Any: + return ("context-of", span) + + def named(self, prefix: str) -> list[RecordedSpan]: + return [span for span in self.spans if span.name.startswith(prefix)] + + +def _recording() -> Any: + import launchdarkly_ai_bedrock_agents.spans as spans_mod + + recorder = SpanRecorder() + return patch.object(spans_mod, "trace", recorder), recorder + + +class TestTelemetryContract: + async def test_maps_cased_stop_reason_and_folds_cache_on_chat_span(self) -> None: + class FakeModel: + async def stream(self, *_args: Any, **_kwargs: Any): + yield { + "contentBlockDelta": {"delta": {"text": "hi"}}, + } + yield { + "metadata": { + "usage": { + "inputTokens": 3, + "outputTokens": 10, + "cacheReadInputTokens": 20, + "cacheWriteInputTokens": 2, + } + } + } + yield {"messageStop": {"stopReason": "End_Turn"}} + + class FakeAgent: + def __init__(self, **kwargs: Any) -> None: + self.model = kwargs["model"] + + async def invoke_async(self, prompt: Any) -> Any: + async for _event in self.model.stream( + [{"role": "user", "content": [{"text": prompt}]}], + None, + "Help Ada.", + ): + pass + raise RuntimeError("provider failed after a billed turn") + + ctx, rec = _recording() + with ( + ctx, + patch("strands.models.BedrockModel", return_value=FakeModel()), + patch("strands.Agent", FakeAgent), + ): + with pytest.raises(RuntimeError, match="billed turn"): + await create_bedrock_agents_handler()( + BASE_CONFIG, "hello", {}, {"name": "Ada"} + ) + + root = rec.named("invoke_agent")[0] + chat = rec.named("chat ")[0] + assert chat.attributes["gen_ai.response.finish_reasons"] == ["stop"] + assert chat.attributes["gen_ai.usage.input_tokens"] == 25 + assert root.attributes["gen_ai.usage.input_tokens"] == 25 + assert root.attributes["gen_ai.system"] == "aws.bedrock" + assert chat.name == "chat us.anthropic.claude-sonnet-4-5" + + async def test_records_history_and_chat_content_when_capture_is_on(self) -> None: + class FakeModel: + async def stream(self, *_args: Any, **_kwargs: Any): + yield {"contentBlockDelta": {"delta": {"text": "done"}}} + yield {"metadata": {"usage": {"inputTokens": 1, "outputTokens": 1}}} + yield {"messageStop": {"stopReason": "end_turn"}} + + class FakeAgent: + def __init__(self, **kwargs: Any) -> None: + self.model = kwargs["model"] + + async def invoke_async(self, prompt: Any) -> Any: + async for _event in self.model.stream(prompt, None, "Be concise."): + pass + return agent_result() + + config = { + **BASE_CONFIG, + "instructions": None, + "messages": [ + {"role": "user", "content": "earlier"}, + {"role": "assistant", "content": "ok"}, + ], + } + ctx, rec = _recording() + with ( + ctx, + patch("strands.models.BedrockModel", return_value=FakeModel()), + patch("strands.Agent", FakeAgent), + ): + await create_bedrock_agents_handler(capture_content=True)(config, "hello") + + root = rec.named("invoke_agent")[0] + chat = rec.named("chat ")[0] + assert "gen_ai.input.messages" in root.attributes + assert "gen_ai.input.messages" in chat.attributes + assert "gen_ai.output.messages" in chat.attributes + assert "earlier" in root.attributes["gen_ai.input.messages"] + + +class _RecordingSpan: + def __init__(self, name: str) -> None: + self.name = name + self.attributes: dict[str, Any] = {} + self.ended = False + + def set_attribute(self, key: str, value: Any) -> None: + self.attributes[key] = value + + def set_status(self, *_args: Any) -> None: + pass + + def record_exception(self, _error: BaseException) -> None: + pass + + def end(self) -> None: + self.ended = True + + def is_recording(self) -> bool: + return not self.ended + + +def _new_span(spans: list[_RecordingSpan], name: str) -> _RecordingSpan: + span = _RecordingSpan(name) + spans.append(span) + return span diff --git a/packages/bedrock-agents/tests/test_sdk_usage.py b/packages/bedrock-agents/tests/test_sdk_usage.py new file mode 100644 index 00000000..2e45739d --- /dev/null +++ b/packages/bedrock-agents/tests/test_sdk_usage.py @@ -0,0 +1,72 @@ +"""Tests for TESTING.md §3.27 — bedrock-agents.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import launchdarkly_ai_server.lifecycle as lifecycle_module +from launchdarkly_ai_bedrock_agents import ( + bedrock_agents, + bedrock_graph, + create_bedrock_agents_handler, +) +from launchdarkly_ai_bedrock_agents._version import PACKAGE_NAME +from launchdarkly_ai_bedrock_agents._version import __version__ as PACKAGE_VERSION +from launchdarkly_ai_server import SDK_INFO_CONTEXT, __version__, init_client, shutdown + +CONTEXT = {"kind": "user", "key": "user-1"} + + +@pytest.fixture +async def client() -> Any: + await shutdown() + installed = MagicMock() + installed.variation = AsyncMock(return_value=None) + installed.flush = AsyncMock() + installed.close = AsyncMock() + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + await init_client(client=installed) + installed.track.reset_mock() + yield installed + await shutdown() + + +@pytest.mark.parametrize( + ("helper", "call"), + [ + ("bedrock-agents.bedrockAgents", lambda: bedrock_agents("k", "q", CONTEXT)), + ("bedrock-agents.createBedrockAgentsHandler", create_bedrock_agents_handler), + ("bedrock-agents.bedrockGraph", lambda: bedrock_graph("k")), + ( + "bedrock-agents.bedrockGraph", + lambda: bedrock_graph("k").invoke("q", CONTEXT), + ), + ], +) +async def test_helper_sends_one_usage_event( + client: MagicMock, helper: str, call: Any +) -> None: + try: + value = call() + if hasattr(value, "__await__"): + await value + elif hasattr(value, "invoke"): + await value.invoke("q") + except Exception: + pass + calls = [ + entry + for entry in client.track.call_args_list + if entry.args[0] == "$ld:ai:sdk:usage" + ] + assert [entry.args[2].get("helper") for entry in calls] == [helper] + assert calls[0].args[1] == SDK_INFO_CONTEXT + assert calls[0].args[2] == { + "aiSdkName": "launchdarkly-ai-server", + "aiSdkVersion": __version__, + "aiSdkLanguage": "python", + "helper": helper, + "helperPackageName": PACKAGE_NAME, + "helperPackageVersion": PACKAGE_VERSION, + } diff --git a/packages/bedrock-messages/CHANGELOG.md b/packages/bedrock-messages/CHANGELOG.md new file mode 100644 index 00000000..c37372f9 --- /dev/null +++ b/packages/bedrock-messages/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.0.1 + +- Initial Amazon Bedrock Runtime Converse and ConverseStream handler. diff --git a/packages/bedrock-messages/README.md b/packages/bedrock-messages/README.md new file mode 100644 index 00000000..3c9ac705 --- /dev/null +++ b/packages/bedrock-messages/README.md @@ -0,0 +1,14 @@ +# LaunchDarkly AI Bedrock Messages + +Amazon Bedrock Runtime Converse and ConverseStream integration for the LaunchDarkly AI SDK. + +```python +from launchdarkly_ai_bedrock_messages import create_bedrock_messages_handler + +handler = create_bedrock_messages_handler(region="us-east-1") +``` + +`model.region` is the Bedrock inference-profile prefix (`us`, `eu`, or `global`), while the +factory `region` selects the AWS endpoint. Authentication uses `api_key`, +`AWS_BEARER_TOKEN_BEDROCK`, or the normal AWS credential chain. A supplied aioboto3 or boto3 +client is used as-is and is never closed by the handler. diff --git a/packages/bedrock-messages/agents.md b/packages/bedrock-messages/agents.md new file mode 100644 index 00000000..143258a0 --- /dev/null +++ b/packages/bedrock-messages/agents.md @@ -0,0 +1,15 @@ +# Agent Guide — `launchdarkly-ai-bedrock-messages` + +This Tier 1 package routes `('Bedrock', 'messages')` configs through Bedrock Runtime Converse. +`handler.py` owns prompt, history, tool, client, and streaming translation; `spans.py` owns the +provider-neutral `invoke_agent` / `chat {model}` / `execute_tool {name}` span tree. + +Important invariants: + +- `model.region` is an inference-profile prefix, never an AWS endpoint region. +- `converse_options(config)` runs for every provider turn, but generated model, messages, system, + and tool-result fields remain authoritative. +- `model.custom` is never consumed. +- Injected async and sync clients are caller-owned. Sync methods run with `asyncio.to_thread`. +- Handler-created aioboto3 clients remain inside their async context for the full invocation. +- Bedrock cache read/write tokens are folded into telemetry input usage while retained as detail. diff --git a/packages/bedrock-messages/pyproject.toml b/packages/bedrock-messages/pyproject.toml new file mode 100644 index 00000000..259344bd --- /dev/null +++ b/packages/bedrock-messages/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "launchdarkly-ai-bedrock-messages" +version = "0.0.1" +requires-python = ">=3.12" +dependencies = [ + "aioboto3>=15.0", + "boto3>=1.40", + "launchdarkly-ai-server", + "opentelemetry-api>=1.25", +] +description = "Amazon Bedrock Converse handler for LaunchDarkly AI SDK" +readme = "README.md" +license = "Apache-2.0" +authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] +keywords = ["launchdarkly", "ai", "aws", "bedrock"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", +] + +[project.urls] +Homepage = "https://github.com/launchdarkly/python-ai-sdk" +Repository = "https://github.com/launchdarkly/python-ai-sdk" +"Bug Tracker" = "https://github.com/launchdarkly/python-ai-sdk/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/launchdarkly_ai_bedrock_messages"] diff --git a/packages/bedrock-messages/src/launchdarkly_ai_bedrock_messages/__init__.py b/packages/bedrock-messages/src/launchdarkly_ai_bedrock_messages/__init__.py new file mode 100644 index 00000000..8d95ab65 --- /dev/null +++ b/packages/bedrock-messages/src/launchdarkly_ai_bedrock_messages/__init__.py @@ -0,0 +1,10 @@ +"""LaunchDarkly AI SDK integration for Amazon Bedrock Converse.""" + +from launchdarkly_ai_server import register_ai_sdk_package + +from ._version import PACKAGE_NAME, __version__ +from .handler import bedrock_messages, create_bedrock_messages_handler + +__all__ = ["bedrock_messages", "create_bedrock_messages_handler"] + +register_ai_sdk_package(PACKAGE_NAME, __version__) diff --git a/packages/bedrock-messages/src/launchdarkly_ai_bedrock_messages/_version.py b/packages/bedrock-messages/src/launchdarkly_ai_bedrock_messages/_version.py new file mode 100644 index 00000000..a1046c38 --- /dev/null +++ b/packages/bedrock-messages/src/launchdarkly_ai_bedrock_messages/_version.py @@ -0,0 +1,4 @@ +"""This package's identity, as sent with ``$ld:ai:sdk:info`` and ``$ld:ai:sdk:usage``.""" + +PACKAGE_NAME = "launchdarkly-ai-bedrock-messages" +__version__ = "0.0.1" # x-release-please-version diff --git a/packages/bedrock-messages/src/launchdarkly_ai_bedrock_messages/handler.py b/packages/bedrock-messages/src/launchdarkly_ai_bedrock_messages/handler.py new file mode 100644 index 00000000..81753b7e --- /dev/null +++ b/packages/bedrock-messages/src/launchdarkly_ai_bedrock_messages/handler.py @@ -0,0 +1,738 @@ +"""Amazon Bedrock Runtime Converse handler.""" + +from __future__ import annotations + +import asyncio +import base64 +import inspect +import json +import os +from collections.abc import AsyncGenerator, AsyncIterator, Callable +from contextlib import asynccontextmanager +from typing import Any + +from launchdarkly_ai_server import ( + AiConfigRep, + LDContext, + ProviderHandler, + SpanMessage, + SpanMessagePart, + _config, + _create_handler, + end_span_once, + end_unfinished_spans, + parse_template, + report_usage, + set_input_content_attributes, + set_output_content_attributes, + set_tool_call_content_attributes, +) +from launchdarkly_ai_server.lifecycle import effective_capture_content + +from ._version import PACKAGE_NAME, __version__ +from .spans import ( + fail_span, + finish_model_span, + finish_root_span, + mark_ok, + model_id, + parent_context_of, + span_messages, + start_model_span, + start_root_span, + start_tool_span, + succeed_span, + tool_definitions, + usage_of, +) + +ConverseOptions = Callable[[AiConfigRep], dict[str, Any]] +_MAX_STEPS = 10 +_BEARER_ENV_LOCK = asyncio.Lock() + + +def _content_blocks(content: Any) -> list[dict[str, Any]]: + if isinstance(content, str): + return [{"text": content}] + if not isinstance(content, list): + return [{"text": str(content or "")}] + result: list[dict[str, Any]] = [] + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text": + result.append({"text": str(block.get("text", ""))}) + elif block.get("type") == "image": + source = block.get("source") or {} + media_type = str(source.get("media_type", "image/png")) + image_format = media_type.rsplit("/", 1)[-1].replace("jpg", "jpeg") + if source.get("type") == "base64": + result.append( + { + "image": { + "format": image_format, + "source": { + "bytes": base64.b64decode(str(source.get("data", ""))) + }, + } + } + ) + elif source.get("type") == "s3": + result.append( + { + "image": { + "format": image_format, + "source": {"s3Location": {"uri": source.get("url", "")}}, + } + } + ) + elif source.get("type") == "url": + raise ValueError( + "Bedrock Converse does not accept remote image URLs; use base64 or s3" + ) + return result + + +def _build_prompt( + cfg: AiConfigRep, + user_input: str | None, + variables: dict[str, Any], + history: list[dict[str, Any]] | None, + *, + output_format: bool, +) -> tuple[list[dict[str, Any]], str | None]: + messages: list[dict[str, Any]] = [] + system: str | None = None + if cfg.get("messages"): + systems = [m for m in cfg["messages"] if m.get("role") == "system"] + if systems: + system = parse_template( + "\n".join(str(m.get("content", "")) for m in systems), variables + ) + for message in cfg["messages"]: + if message.get("role") not in ("user", "assistant"): + continue + messages.append( + { + "role": message["role"], + "content": [ + { + "text": parse_template( + str(message.get("content", "")), variables + ) + } + ], + } + ) + elif cfg.get("instructions"): + system = parse_template(str(cfg["instructions"]), variables) + + for message in history or []: + if message.get("role") in ("user", "assistant"): + messages.append( + { + "role": message["role"], + "content": _content_blocks(message.get("content", "")), + } + ) + + if user_input or not messages or messages[-1]["role"] != "user": + messages.append({"role": "user", "content": [{"text": user_input or ""}]}) + + if output_format and cfg.get("outputFormat"): + instruction = "Respond with valid JSON matching this schema:\n" + json.dumps( + cfg["outputFormat"], separators=(",", ":") + ) + system = f"{system}\n\n{instruction}" if system else instruction + return messages, system + + +def _tools(cfg: AiConfigRep, handlers: dict[str, Any]) -> list[dict[str, Any]]: + result = [] + for name, definition in (cfg.get("tools") or {}).items(): + if not callable(handlers.get(name)): + continue + result.append( + { + "toolSpec": { + "name": name, + "description": definition.get("description", ""), + "inputSchema": {"json": definition.get("parameters") or {}}, + } + } + ) + return result + + +def _inference_config(cfg: AiConfigRep) -> dict[str, Any]: + parameters = cfg.get("model", {}).get("parameters") or {} + aliases = { + "maxTokens": "maxTokens", + "max_tokens": "maxTokens", + "temperature": "temperature", + "topP": "topP", + "top_p": "topP", + "stopSequences": "stopSequences", + "stop_sequences": "stopSequences", + } + return { + target: parameters[source] + for source, target in aliases.items() + if source in parameters + } + + +def _request( + cfg: AiConfigRep, + messages: list[dict[str, Any]], + system: str | None, + tools: list[dict[str, Any]], + options: ConverseOptions | None, +) -> dict[str, Any]: + request = dict(options(cfg) or {}) if options else {} + inference = _inference_config(cfg) + if inference: + request["inferenceConfig"] = inference + if tools: + request["toolConfig"] = {"tools": tools} + request["modelId"] = model_id(cfg) + request["messages"] = messages + if system: + request["system"] = [{"text": system}] + else: + request.pop("system", None) + return request + + +async def _invoke_method(client: Any, name: str, kwargs: dict[str, Any]) -> Any: + method = getattr(client, name) + if inspect.iscoroutinefunction(method): + return await method(**kwargs) + value = await asyncio.to_thread(method, **kwargs) + return await value if inspect.isawaitable(value) else value + + +async def _execute_tool( + block: dict[str, Any], + handlers: dict[str, Any], + parent: Any, + capture_content: bool, + open_tool_spans: dict[str, Any] | None = None, +) -> dict[str, Any]: + use = block["toolUse"] + name = str(use.get("name", "")) + call_id = str(use.get("toolUseId", "")) + arguments = use.get("input") or {} + span = start_tool_span(name, call_id, parent) + if open_tool_spans is not None: + open_tool_spans[call_id] = span + try: + set_tool_call_content_attributes(span, capture_content, arguments=arguments) + handler = handlers.get(name) + if not callable(handler): + raise ValueError(f'No handler registered for tool "{name}"') + result = handler(arguments) + if inspect.isawaitable(result): + result = await result + set_tool_call_content_attributes(span, capture_content, result=result) + succeed_span(span) + if open_tool_spans is not None: + open_tool_spans.pop(call_id, None) + return { + "toolResult": { + "toolUseId": call_id, + "content": [{"json": result}] + if isinstance(result, (dict, list, int, float, bool)) or result is None + else [{"text": str(result)}], + } + } + except asyncio.CancelledError: + if open_tool_spans is not None: + open_tool_spans.pop(call_id, None) + end_span_once(span, set(), abandoned=True, cancelled=True) + raise + except Exception as exc: + fail_span(span, exc) + if open_tool_spans is not None: + open_tool_spans.pop(call_id, None) + raise + + +class _Usage: + def __init__(self) -> None: + self.raw: dict[str, int] = { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + } + from launchdarkly_ai_server import SpanUsage + + self.span = SpanUsage() + self.reported = False + + def add(self, usage: Any) -> None: + if not isinstance(usage, dict): + return + raw, span = usage_of(usage) + self.reported = True + for key, value in raw.items(): + self.raw[key] = self.raw.get(key, 0) + value + self.span.input += span.input + self.span.output += span.output + self.span.cache_read += span.cache_read + self.span.cache_creation += span.cache_creation + + +@asynccontextmanager +async def _client_scope( + injected: Any, api_key: str | None, region: str | None +) -> AsyncIterator[Any]: + if injected is not None: + yield injected + return + + import aioboto3 # type: ignore[import-untyped] + + kwargs: dict[str, Any] = {} + if region: + kwargs["region_name"] = region + if not api_key: + async with aioboto3.Session().client("bedrock-runtime", **kwargs) as client: + yield client + return + + # Botocore exposes Bedrock bearer authentication through this environment provider rather than + # as a create_client argument. Scope the override to client ownership and serialize explicit + # token scopes so two handlers cannot overwrite one another. + async with _BEARER_ENV_LOCK: + previous = os.environ.get("AWS_BEARER_TOKEN_BEDROCK") + os.environ["AWS_BEARER_TOKEN_BEDROCK"] = api_key + try: + async with aioboto3.Session().client("bedrock-runtime", **kwargs) as client: + yield client + finally: + if previous is None: + os.environ.pop("AWS_BEARER_TOKEN_BEDROCK", None) + else: + os.environ["AWS_BEARER_TOKEN_BEDROCK"] = previous + + +async def _call( + client: Any, + cfg: AiConfigRep, + user_input: str | None, + handlers: dict[str, Any], + variables: dict[str, Any], + history: list[dict[str, Any]] | None, + options: ConverseOptions | None, + capture_content: bool, +) -> dict[str, Any]: + messages, system = _build_prompt( + cfg, user_input, variables, history, output_format=True + ) + tools = _tools(cfg, handlers) + root = start_root_span(cfg, variables) + open_root: Any = root + parent = parent_context_of(root) + usage = _Usage() + open_model: Any = None + open_tool_spans: dict[str, Any] = {} + try: + set_input_content_attributes( + root, + capture_content, + system_instructions=system, + messages=span_messages(messages), + ) + for step in range(_MAX_STEPS + 1): + model_span = start_model_span(cfg, parent) + open_model = model_span + try: + if capture_content: + set_input_content_attributes( + model_span, + capture_content, + system_instructions=system, + messages=span_messages(messages), + tool_definitions=tool_definitions(tools), + ) + response = await _invoke_method( + client, "converse", _request(cfg, messages, system, tools, options) + ) + _, turn_usage = usage_of(response.get("usage")) + usage.add(response.get("usage")) + reason = response.get("stopReason") + output_message = response.get("output", {}).get("message", {}) + content = output_message.get("content") or [] + if capture_content: + set_output_content_attributes( + model_span, + capture_content, + [ + SpanMessage( + role="assistant", + parts=span_messages( + [{"role": "assistant", "content": content}] + )[0].parts, + finish_reason=reason, + ) + ], + ) + finish_model_span(model_span, cfg, turn_usage, reason) + open_model = None + except Exception as exc: + fail_span(model_span, exc) + open_model = None + raise + + if reason != "tool_use": + output = "".join(str(block.get("text", "")) for block in content) + set_output_content_attributes( + root, + capture_content, + [ + SpanMessage( + role="assistant", + parts=[SpanMessagePart(type="text", content=output)], + ) + ], + ) + finish_root_span(root, cfg, usage.span) + succeed_span(root) + open_root = None + return {"output": output, "usage": usage.raw} + if step == _MAX_STEPS: + raise RuntimeError(f"Tool loop exceeded {_MAX_STEPS} steps") + messages.append({"role": "assistant", "content": content}) + results = [ + await _execute_tool( + block, + handlers, + parent, + capture_content, + open_tool_spans, + ) + for block in content + if "toolUse" in block + ] + messages.append({"role": "user", "content": results}) + raise AssertionError("unreachable") + except Exception as exc: + if usage.reported: + finish_root_span(root, cfg, usage.span) + fail_span(root, exc) + open_root = None + raise + finally: + end_unfinished_spans( + *open_tool_spans.values(), + open_model, + open_root, + ) + + +async def _stream_events(stream: Any) -> AsyncIterator[dict[str, Any]]: + if hasattr(stream, "__aiter__"): + async for event in stream: + yield event + else: + iterator = iter(stream) + + def next_event() -> tuple[bool, Any]: + try: + return True, next(iterator) + except StopIteration: + return False, None + + while True: + present, event = await asyncio.to_thread(next_event) + if not present: + break + yield event + + +async def _stream( + client: Any, + cfg: AiConfigRep, + user_input: str | None, + handlers: dict[str, Any], + variables: dict[str, Any], + history: list[dict[str, Any]] | None, + options: ConverseOptions | None, + capture_content: bool, +) -> AsyncGenerator[dict[str, Any], None]: + messages, system = _build_prompt( + cfg, user_input, variables, history, output_format=False + ) + tools = _tools(cfg, handlers) + root = start_root_span(cfg, variables) + parent = parent_context_of(root) + usage = _Usage() + full_output = "" + ended: set[int] = set() + open_model: Any = None + open_tool_spans: dict[str, Any] = {} + cancelled = False + try: + set_input_content_attributes( + root, + capture_content, + system_instructions=system, + messages=span_messages(messages), + ) + for step in range(_MAX_STEPS + 1): + model_span = start_model_span(cfg, parent) + open_model = model_span + content: list[dict[str, Any]] = [] + text = "" + reason: str | None = None + turn_usage_data: dict[str, Any] | None = None + tool_by_index: dict[int, dict[str, Any]] = {} + try: + if capture_content: + set_input_content_attributes( + model_span, + capture_content, + system_instructions=system, + messages=span_messages(messages), + tool_definitions=tool_definitions(tools), + ) + response = await _invoke_method( + client, + "converse_stream", + _request(cfg, messages, system, tools, options), + ) + async for event in _stream_events(response["stream"]): + if "contentBlockStart" in event: + start = event["contentBlockStart"] + if "toolUse" in start.get("start", {}): + tool_by_index[start.get("contentBlockIndex", 0)] = { + "toolUse": { + **start["start"]["toolUse"], + "input": "", + } + } + elif "contentBlockDelta" in event: + delta_event = event["contentBlockDelta"] + delta = delta_event.get("delta", {}) + if "text" in delta: + chunk = str(delta["text"]) + text += chunk + full_output += chunk + yield {"type": "chunk", "text": chunk} + elif "toolUse" in delta: + block = tool_by_index[ + delta_event.get("contentBlockIndex", 0) + ] + block["toolUse"]["input"] += str( + delta["toolUse"].get("input", "") + ) + elif "messageStop" in event: + reason = event["messageStop"].get("stopReason") + elif "metadata" in event: + turn_usage_data = event["metadata"].get("usage") or {} + content.append({"text": text}) if text else None + for block in tool_by_index.values(): + raw_input = block["toolUse"].get("input", "") + try: + block["toolUse"]["input"] = json.loads(raw_input or "{}") + except json.JSONDecodeError: + block["toolUse"]["input"] = raw_input + content.append(block) + _, turn_usage = usage_of(turn_usage_data) + usage.add(turn_usage_data) + if capture_content: + set_output_content_attributes( + model_span, + capture_content, + [ + SpanMessage( + role="assistant", + parts=span_messages( + [{"role": "assistant", "content": content}] + )[0].parts, + finish_reason=reason, + ) + ], + ) + finish_model_span(model_span, cfg, turn_usage, reason) + open_model = None + except Exception as exc: + fail_span(model_span, exc, ended) + open_model = None + raise + + if reason != "tool_use": + set_output_content_attributes( + root, + capture_content, + [ + SpanMessage( + role="assistant", + parts=[SpanMessagePart(type="text", content=full_output)], + ) + ], + ) + finish_root_span(root, cfg, usage.span) + mark_ok(root) + end_span_once(root, ended) + yield {"type": "done", "output": full_output, "usage": usage.raw} + return + if step == _MAX_STEPS: + raise RuntimeError(f"Tool loop exceeded {_MAX_STEPS} steps") + messages.append({"role": "assistant", "content": content}) + messages.append( + { + "role": "user", + "content": [ + await _execute_tool( + block, + handlers, + parent, + capture_content, + open_tool_spans, + ) + for block in content + if "toolUse" in block + ], + } + ) + except asyncio.CancelledError: + cancelled = True + raise + except Exception as exc: + if usage.reported: + finish_root_span(root, cfg, usage.span) + fail_span(root, exc, ended) + raise + finally: + for open_tool_span in open_tool_spans.values(): + end_span_once( + open_tool_span, + ended, + abandoned=True, + cancelled=cancelled, + ) + open_tool_spans.clear() + if open_model is not None: + end_span_once( + open_model, + ended, + abandoned=True, + cancelled=cancelled, + ) + if root is not None and id(root) not in ended and usage.reported: + finish_root_span(root, cfg, usage.span) + end_span_once(root, ended, abandoned=True, cancelled=cancelled) + + +def create_bedrock_messages_handler( + *, + client: Any = None, + api_key: str | None = None, + region: str | None = None, + converse_options: ConverseOptions | None = None, + capture_content: bool | None = None, +) -> ProviderHandler: + """Create a Bedrock Converse handler. + + ``model.region`` is an inference-profile prefix. ``region`` configures only the AWS endpoint. + Injected clients are used verbatim and are never closed. + """ + report_usage( + "bedrock-messages.createBedrockMessagesHandler", PACKAGE_NAME, __version__ + ) + return _create_bedrock_messages_handler( + client=client, + api_key=api_key, + region=region, + converse_options=converse_options, + capture_content=capture_content, + ) + + +def _create_bedrock_messages_handler( + *, + client: Any = None, + api_key: str | None = None, + region: str | None = None, + converse_options: ConverseOptions | None = None, + capture_content: bool | None = None, +) -> ProviderHandler: + """Non-reporting :func:`create_bedrock_messages_handler`, used by this package's wrappers.""" + + async def invoke( + cfg: AiConfigRep, + user_input: str | None = None, + tool_handlers: dict[str, Any] | None = None, + variables: dict[str, Any] | None = None, + history: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + capture = effective_capture_content(capture_content) + async with _client_scope(client, api_key, region) as runtime: + return await _call( + runtime, + cfg, + user_input, + tool_handlers or {}, + variables or {}, + history, + converse_options, + capture, + ) + + async def stream( + cfg: AiConfigRep, + user_input: str | None = None, + tool_handlers: dict[str, Any] | None = None, + variables: dict[str, Any] | None = None, + history: list[dict[str, Any]] | None = None, + ) -> AsyncGenerator[dict[str, Any], None]: + capture = effective_capture_content(capture_content) + async with _client_scope(client, api_key, region) as runtime: + provider_stream = _stream( + runtime, + cfg, + user_input, + tool_handlers or {}, + variables or {}, + history, + converse_options, + capture, + ) + try: + async for event in provider_stream: + yield event + finally: + await provider_stream.aclose() + + return _create_handler( + ("Bedrock", "messages"), + invoke, + stream, + capture_content=capture_content, + ) + + +def bedrock_messages( + config_key: str, + user_input: str, + context: LDContext, + **kwargs: Any, +) -> Any: + """Invoke a LaunchDarkly AI config using Bedrock Converse.""" + report_usage("bedrock-messages.bedrockMessages", PACKAGE_NAME, __version__) + variables = kwargs.pop("variables", None) + factory_keys = ( + "client", + "api_key", + "region", + "converse_options", + "capture_content", + ) + factory = {key: kwargs.pop(key) for key in factory_keys if key in kwargs} + return _config( + key=config_key, + handler=_create_bedrock_messages_handler(**factory), + **kwargs, + ).invoke(user_input, context, variables=variables) diff --git a/packages/bedrock-messages/src/launchdarkly_ai_bedrock_messages/spans.py b/packages/bedrock-messages/src/launchdarkly_ai_bedrock_messages/spans.py new file mode 100644 index 00000000..55931138 --- /dev/null +++ b/packages/bedrock-messages/src/launchdarkly_ai_bedrock_messages/spans.py @@ -0,0 +1,201 @@ +"""OpenTelemetry span helpers for Bedrock Converse.""" + +from __future__ import annotations + +from typing import Any + +from launchdarkly_ai_server import ( + AiConfigRep, + SpanMessage, + SpanMessagePart, + SpanUsage, + ToolDefinitionInput, + number_or_zero, + set_ld_span_attributes, + set_model_identity_attributes, + set_usage_span_attributes, +) + +try: + from opentelemetry import trace + from opentelemetry.trace import StatusCode + + _HAS_OTEL = True +except ImportError: # pragma: no cover + _HAS_OTEL = False + +TRACER_NAME = "@launchdarkly/ai-bedrock-messages" +PROVIDER = "aws.bedrock" + + +def model_id(config: AiConfigRep) -> str: + model = config.get("model", {}) + name = str(model.get("name", "")) + prefix = str(model.get("region") or "") + return name if not prefix or name.startswith(f"{prefix}.") else f"{prefix}.{name}" + + +def model_name(config: AiConfigRep) -> str: + """Shared span surface name for Bedrock's resolved model ID.""" + return model_id(config) + + +def start_root_span(config: AiConfigRep, variables: dict[str, Any]) -> Any: + if not _HAS_OTEL: + return None + span = trace.get_tracer(TRACER_NAME).start_span("invoke_agent") + span.set_attribute("gen_ai.operation.name", "invoke_agent") + set_model_identity_attributes(span, PROVIDER, model_id(config)) + set_ld_span_attributes(span, variables) + return span + + +def parent_context_of(span: Any) -> Any: + return trace.set_span_in_context(span) if _HAS_OTEL and span is not None else None + + +def start_model_span(config: AiConfigRep, parent: Any) -> Any: + if not _HAS_OTEL: + return None + name = model_id(config) + span = trace.get_tracer(TRACER_NAME).start_span(f"chat {name}", context=parent) + span.set_attribute("gen_ai.operation.name", "chat") + set_model_identity_attributes(span, PROVIDER, name) + return span + + +def start_tool_span(name: str, call_id: str, parent: Any) -> Any: + if not _HAS_OTEL: + return None + span = trace.get_tracer(TRACER_NAME).start_span( + f"execute_tool {name}", context=parent + ) + span.set_attribute("gen_ai.operation.name", "execute_tool") + span.set_attribute("gen_ai.tool.name", name) + span.set_attribute("gen_ai.tool.call.id", call_id) + return span + + +def usage_of(value: Any) -> tuple[dict[str, int], SpanUsage]: + value = value if isinstance(value, dict) else {} + raw = { + "input_tokens": number_or_zero(value.get("inputTokens")), + "output_tokens": number_or_zero(value.get("outputTokens")), + "total_tokens": number_or_zero(value.get("totalTokens")), + } + cache_read = number_or_zero(value.get("cacheReadInputTokens")) + cache_write = number_or_zero(value.get("cacheWriteInputTokens")) + if "cacheReadInputTokens" in value: + raw["cache_read_input_tokens"] = cache_read + if "cacheWriteInputTokens" in value: + raw["cache_creation_input_tokens"] = cache_write + return raw, SpanUsage( + input=raw["input_tokens"] + cache_read + cache_write, + output=raw["output_tokens"], + cache_read=cache_read, + cache_creation=cache_write, + ) + + +def finish_model_span( + span: Any, config: AiConfigRep, usage: SpanUsage, reason: str | None +) -> None: + if span is None: + return + span.set_attribute("gen_ai.response.model", model_id(config)) + if reason: + span.set_attribute("gen_ai.response.finish_reasons", [finish_reason(reason)]) + set_usage_span_attributes(span, usage) + span.set_status(StatusCode.OK) + span.end() + + +def finish_root_span(span: Any, config: AiConfigRep, usage: SpanUsage) -> None: + if span is None: + return + span.set_attribute("gen_ai.response.model", model_id(config)) + set_usage_span_attributes(span, usage) + + +def finish_reason(reason: str) -> str: + return { + "end_turn": "stop", + "stop_sequence": "stop", + "max_tokens": "length", + "tool_use": "tool_calls", + "refusal": "content_filter", + }.get(reason.lower(), reason) + + +def succeed_span(span: Any) -> None: + if span is not None: + span.set_status(StatusCode.OK) + span.end() + + +def mark_ok(span: Any) -> None: + if span is not None: + span.set_status(StatusCode.OK) + + +def fail_span(span: Any, error: BaseException, ended: set[int] | None = None) -> None: + if span is None: + return + span.record_exception(error) + span.set_status(StatusCode.ERROR, str(error)) + if ended is None: + span.end() + else: + from launchdarkly_ai_server import end_span_once + + end_span_once(span, ended) + + +def content_parts(content: Any) -> list[SpanMessagePart]: + if not isinstance(content, list): + return [] + parts: list[SpanMessagePart] = [] + for block in content: + if "text" in block: + parts.append(SpanMessagePart(type="text", content=str(block["text"]))) + elif "toolUse" in block: + use = block["toolUse"] + parts.append( + SpanMessagePart( + type="tool_call", + id=use.get("toolUseId"), + name=str(use.get("name", "")), + arguments=use.get("input"), + ) + ) + elif "toolResult" in block: + result = block["toolResult"] + parts.append( + SpanMessagePart( + type="tool_call_response", + id=result.get("toolUseId"), + result=result.get("content"), + ) + ) + return parts + + +def span_messages(messages: list[dict[str, Any]]) -> list[SpanMessage]: + return [ + SpanMessage( + role=str(message.get("role", "")), + parts=content_parts(message.get("content")), + ) + for message in messages + ] + + +def tool_definitions(tools: list[dict[str, Any]]) -> list[ToolDefinitionInput]: + return [ + ToolDefinitionInput( + name=str(tool["toolSpec"].get("name", "")), + description=tool["toolSpec"].get("description"), + parameters=tool["toolSpec"].get("inputSchema", {}).get("json"), + ) + for tool in tools + ] diff --git a/packages/bedrock-messages/tests/test_handler.py b/packages/bedrock-messages/tests/test_handler.py new file mode 100644 index 00000000..72aa96f0 --- /dev/null +++ b/packages/bedrock-messages/tests/test_handler.py @@ -0,0 +1,523 @@ +"""Contract tests for the Bedrock Converse messages handler.""" + +from __future__ import annotations + +import asyncio +import threading +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from launchdarkly_ai_bedrock_messages import create_bedrock_messages_handler +from launchdarkly_ai_bedrock_messages import handler as handler_module + +BASE_CONFIG: dict[str, Any] = { + "model": { + "name": "anthropic.claude-sonnet-4-5", + "region": "us", + "parameters": {"maxTokens": 256, "temperature": 0.2}, + "custom": {"mustNeverLeak": "sentinel"}, + }, + "provider": {"name": "Bedrock"}, + "instructions": "Be concise.", +} + + +def response(text: str = "hello") -> dict[str, Any]: + return { + "output": {"message": {"role": "assistant", "content": [{"text": text}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 7, "outputTokens": 3, "totalTokens": 10}, + } + + +class TestFactoryAndModelId: + async def test_routes_as_bedrock_messages(self) -> None: + client = MagicMock() + client.converse = AsyncMock(return_value=response()) + + handler = create_bedrock_messages_handler(client=client) + + assert handler.provides_for == ("Bedrock", "messages") + result = await handler(BASE_CONFIG, "hello") + assert result["output"] == "hello" + assert result["usage"] == { + "input_tokens": 7, + "output_tokens": 3, + "total_tokens": 10, + } + + @pytest.mark.parametrize( + ("region", "name", "expected"), + [ + ("us", "anthropic.claude-sonnet-4-5", "us.anthropic.claude-sonnet-4-5"), + ("us", "us.anthropic.claude-sonnet-4-5", "us.anthropic.claude-sonnet-4-5"), + (None, "anthropic.claude-sonnet-4-5", "anthropic.claude-sonnet-4-5"), + ], + ) + async def test_short_region_prefix_is_prepended_once( + self, region: str | None, name: str, expected: str + ) -> None: + client = MagicMock() + client.converse = AsyncMock(return_value=response()) + config = { + **BASE_CONFIG, + "model": {**BASE_CONFIG["model"], "name": name, "region": region}, + } + + await create_bedrock_messages_handler(client=client)(config, "hello") + + assert client.converse.await_args.kwargs["modelId"] == expected + + async def test_does_not_double_prepend_matching_region_prefix(self) -> None: + client = MagicMock() + client.converse = AsyncMock(return_value=response()) + config = { + **BASE_CONFIG, + "model": { + **BASE_CONFIG["model"], + "region": "us", + "name": "us.anthropic.claude-sonnet-4-5", + }, + } + + await create_bedrock_messages_handler(client=client)(config, "hello") + + model_id = client.converse.await_args.kwargs["modelId"] + assert model_id == "us.anthropic.claude-sonnet-4-5" + assert model_id != "us.us.anthropic.claude-sonnet-4-5" + + +class TestOptionsAndClientEscapeHatch: + async def test_options_callback_exposes_converse_without_mapping_custom( + self, + ) -> None: + client = MagicMock() + client.converse = AsyncMock(return_value=response()) + options = MagicMock( + return_value={ + "guardrailConfig": { + "guardrailIdentifier": "guardrail-id", + "guardrailVersion": "1", + }, + "requestMetadata": {"tenant": "acme"}, + } + ) + + await create_bedrock_messages_handler(client=client, converse_options=options)( + BASE_CONFIG, "hello" + ) + + options.assert_called_once_with(BASE_CONFIG) + request = client.converse.await_args.kwargs + assert request["guardrailConfig"]["guardrailIdentifier"] == "guardrail-id" + assert request["requestMetadata"] == {"tenant": "acme"} + assert "mustNeverLeak" not in repr(request) + + async def test_handler_critical_fields_override_options_callback(self) -> None: + client = MagicMock() + client.converse = AsyncMock(return_value=response()) + + await create_bedrock_messages_handler( + client=client, + converse_options=lambda _config: { + "modelId": "wrong", + "messages": [], + "system": [], + }, + )(BASE_CONFIG, "hello") + + request = client.converse.await_args.kwargs + assert request["modelId"] == "us.anthropic.claude-sonnet-4-5" + assert request["messages"] + assert request["system"] == [{"text": "Be concise."}] + + async def test_injected_async_client_is_used_and_not_closed(self) -> None: + client = MagicMock() + client.converse = AsyncMock(return_value=response()) + client.close = AsyncMock() + + handler = create_bedrock_messages_handler( + client=client, + api_key="must-not-reconfigure-client", + region="us-east-1", + ) + await handler(BASE_CONFIG, "hello") + + client.converse.assert_awaited_once() + client.close.assert_not_awaited() + + async def test_injected_sync_boto3_client_runs_off_event_loop(self) -> None: + event_loop_thread = threading.get_ident() + called_on: list[int] = [] + client = MagicMock() + + def converse(**_kwargs: Any) -> dict[str, Any]: + called_on.append(threading.get_ident()) + return response() + + client.converse = converse + + result = await create_bedrock_messages_handler(client=client)( + BASE_CONFIG, "hello" + ) + + assert result["output"] == "hello" + assert called_on and called_on[0] != event_loop_thread + + +class TestToolsAndStreaming: + async def test_converse_tool_use_executes_handler_and_submits_result(self) -> None: + client = MagicMock() + client.converse = AsyncMock( + side_effect=[ + { + "output": { + "message": { + "role": "assistant", + "content": [ + { + "toolUse": { + "toolUseId": "tool-1", + "name": "lookup", + "input": {"id": 42}, + } + } + ], + } + }, + "stopReason": "tool_use", + "usage": { + "inputTokens": 4, + "outputTokens": 2, + "totalTokens": 6, + }, + }, + response("done"), + ] + ) + config = { + **BASE_CONFIG, + "tools": { + "lookup": { + "name": "lookup", + "description": "Look up an item", + "parameters": { + "type": "object", + "properties": {"id": {"type": "integer"}}, + }, + } + }, + } + lookup = AsyncMock(return_value={"value": "found"}) + + result = await create_bedrock_messages_handler(client=client)( + config, "hello", {"lookup": lookup} + ) + + lookup.assert_awaited_once_with({"id": 42}) + second_messages = client.converse.await_args_list[1].kwargs["messages"] + assert second_messages[-1]["content"][0]["toolResult"]["toolUseId"] == "tool-1" + assert result["output"] == "done" + + async def test_stream_emits_text_and_terminal_usage(self) -> None: + client = MagicMock() + + async def events(): + yield {"contentBlockDelta": {"delta": {"text": "hel"}}} + yield {"contentBlockDelta": {"delta": {"text": "lo"}}} + yield { + "metadata": { + "usage": { + "inputTokens": 5, + "outputTokens": 2, + "totalTokens": 7, + } + } + } + + client.converse_stream = AsyncMock(return_value={"stream": events()}) + handler = create_bedrock_messages_handler(client=client) + + stream = await handler.stream(BASE_CONFIG, "hello") + output = [event async for event in stream] + + assert output == [ + {"type": "chunk", "text": "hel"}, + {"type": "chunk", "text": "lo"}, + { + "type": "done", + "output": "hello", + "usage": { + "input_tokens": 5, + "output_tokens": 2, + "total_tokens": 7, + }, + }, + ] + + async def test_abandoned_stream_closes_model_and_root_spans( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + spans: list[_RecordingSpan] = [] + monkeypatch.setattr( + handler_module, + "start_root_span", + lambda *_args: _new_span(spans, "root"), + ) + monkeypatch.setattr( + handler_module, + "start_model_span", + lambda *_args: _new_span(spans, "model"), + ) + monkeypatch.setattr(handler_module, "parent_context_of", lambda _span: None) + + async def events(): + yield {"contentBlockDelta": {"delta": {"text": "first"}}} + await asyncio.Event().wait() + + client = MagicMock() + client.converse_stream = AsyncMock(return_value={"stream": events()}) + provider_stream = await create_bedrock_messages_handler(client=client).stream( + BASE_CONFIG, "hello" + ) + + assert await anext(provider_stream) == {"type": "chunk", "text": "first"} + await provider_stream.aclose() + + assert [span.name for span in spans] == ["root", "model"] + assert all(not span.is_recording() for span in spans) + assert all( + span.attributes["launchdarkly.stream.abandoned"] is True for span in spans + ) + + async def test_cancelled_stream_tool_closes_tool_and_root_spans( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + spans: list[_RecordingSpan] = [] + monkeypatch.setattr( + handler_module, + "start_root_span", + lambda *_args: _new_span(spans, "root"), + ) + monkeypatch.setattr( + handler_module, + "start_model_span", + lambda *_args: _new_span(spans, "model"), + ) + monkeypatch.setattr( + handler_module, + "start_tool_span", + lambda *_args: _new_span(spans, "tool"), + ) + monkeypatch.setattr(handler_module, "parent_context_of", lambda _span: None) + + async def events(): + yield { + "contentBlockStart": { + "contentBlockIndex": 0, + "start": {"toolUse": {"toolUseId": "tool-1", "name": "lookup"}}, + } + } + yield { + "contentBlockDelta": { + "contentBlockIndex": 0, + "delta": {"toolUse": {"input": "{}"}}, + } + } + yield {"messageStop": {"stopReason": "tool_use"}} + yield {"metadata": {"usage": {"inputTokens": 1, "outputTokens": 1}}} + + entered = asyncio.Event() + + async def lookup(_args: dict[str, Any]) -> None: + entered.set() + await asyncio.Event().wait() + + config = { + **BASE_CONFIG, + "tools": { + "lookup": { + "description": "lookup", + "parameters": {"type": "object"}, + } + }, + } + client = MagicMock() + client.converse_stream = AsyncMock(return_value={"stream": events()}) + provider_stream = await create_bedrock_messages_handler(client=client).stream( + config, "hello", {"lookup": lookup} + ) + pending = asyncio.create_task(anext(provider_stream)) + await entered.wait() + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + + assert [span.name for span in spans] == ["root", "model", "tool"] + assert all(not span.is_recording() for span in spans) + assert spans[0].attributes["launchdarkly.run.cancelled"] is True + assert spans[2].attributes["launchdarkly.run.cancelled"] is True + + +class RecordedSpan: + def __init__(self, name: str, context: Any = None) -> None: + self.name = name + self.context = context + self.attributes: dict[str, Any] = {} + self.events: list[tuple[str, dict[str, Any]]] = [] + self.ended = 0 + + def is_recording(self) -> bool: + return self.ended == 0 + + def set_attribute(self, key: str, value: Any) -> None: + self.attributes[key] = value + + def add_event(self, name: str, attributes: dict[str, Any] | None = None) -> None: + self.events.append((name, attributes or {})) + + def set_status(self, *_args: Any) -> None: + pass + + def record_exception(self, _error: BaseException) -> None: + pass + + def end(self) -> None: + self.ended += 1 + + +class SpanRecorder: + def __init__(self) -> None: + self.spans: list[RecordedSpan] = [] + + def get_tracer(self, name: str) -> SpanRecorder: + return self + + def start_span(self, name: str, context: Any = None) -> RecordedSpan: + span = RecordedSpan(name, context) + self.spans.append(span) + return span + + def set_span_in_context(self, span: RecordedSpan) -> Any: + return ("context-of", span) + + def named(self, prefix: str) -> list[RecordedSpan]: + return [span for span in self.spans if span.name.startswith(prefix)] + + +def _recording() -> Any: + import launchdarkly_ai_bedrock_messages.spans as spans_mod + + recorder = SpanRecorder() + return patch.object(spans_mod, "trace", recorder), recorder + + +class TestTelemetryContract: + async def test_root_and_chat_identify_bedrock_and_resolved_model(self) -> None: + client = MagicMock() + client.converse = AsyncMock(return_value=response()) + ctx, rec = _recording() + + with ctx: + await create_bedrock_messages_handler(client=client)(BASE_CONFIG, "hello") + + root = rec.named("invoke_agent")[0] + chat = rec.named("chat ")[0] + model_id = "us.anthropic.claude-sonnet-4-5" + assert root.name == "invoke_agent" + assert chat.name == f"chat {model_id}" + for span in (root, chat): + assert span.attributes["gen_ai.system"] == "aws.bedrock" + assert span.attributes["gen_ai.provider.name"] == "aws.bedrock" + assert span.attributes["gen_ai.request.model"] == model_id + assert span.attributes["gen_ai.response.model"] == model_id + assert chat.attributes["gen_ai.response.finish_reasons"] == ["stop"] + assert chat.context == ("context-of", root) + + async def test_folds_cache_tokens_into_span_input_but_returns_raw_usage( + self, + ) -> None: + client = MagicMock() + client.converse = AsyncMock( + return_value={ + **response(), + "usage": { + "inputTokens": 3, + "outputTokens": 10, + "totalTokens": 13, + "cacheReadInputTokens": 19971, + "cacheWriteInputTokens": 3580, + }, + } + ) + ctx, rec = _recording() + + with ctx: + result = await create_bedrock_messages_handler(client=client)( + BASE_CONFIG, "hello" + ) + + attrs = rec.named("chat ")[0].attributes + assert attrs["gen_ai.usage.input_tokens"] == 23554 + assert attrs["gen_ai.usage.cache_read.input_tokens"] == 19971 + assert attrs["gen_ai.usage.cache_creation.input_tokens"] == 3580 + assert attrs["gen_ai.usage.total_tokens"] == 23564 + assert attrs["gen_ai.usage.prompt_tokens"] == 23554 + assert result["usage"]["input_tokens"] == 3 + assert result["usage"]["cache_read_input_tokens"] == 19971 + assert result["usage"]["cache_creation_input_tokens"] == 3580 + + async def test_stream_writes_the_same_content_carriers_as_invoke(self) -> None: + async def events(): + yield {"contentBlockDelta": {"delta": {"text": "hello"}}} + yield {"messageStop": {"stopReason": "end_turn"}} + yield {"metadata": {"usage": {"inputTokens": 5, "outputTokens": 2}}} + + client = MagicMock() + client.converse_stream = AsyncMock(return_value={"stream": events()}) + ctx, rec = _recording() + + with ctx: + stream = await create_bedrock_messages_handler( + client=client, capture_content=True + ).stream(BASE_CONFIG, "hello") + async for _event in stream: + pass + + chat = rec.named("chat ")[0] + root = rec.named("invoke_agent")[0] + assert "gen_ai.input.messages" in chat.attributes + assert "gen_ai.output.messages" in chat.attributes + assert "gen_ai.output.messages" in root.attributes + assert "gen_ai.prompt.0.role" in chat.attributes + assert chat.attributes["gen_ai.response.finish_reasons"] == ["stop"] + + +class _RecordingSpan: + def __init__(self, name: str) -> None: + self.name = name + self.attributes: dict[str, Any] = {} + self.ended = False + + def set_attribute(self, key: str, value: Any) -> None: + self.attributes[key] = value + + def set_status(self, *_args: Any) -> None: + pass + + def record_exception(self, _error: BaseException) -> None: + pass + + def end(self) -> None: + self.ended = True + + def is_recording(self) -> bool: + return not self.ended + + +def _new_span(spans: list[_RecordingSpan], name: str) -> _RecordingSpan: + span = _RecordingSpan(name) + spans.append(span) + return span diff --git a/packages/bedrock-messages/tests/test_sdk_usage.py b/packages/bedrock-messages/tests/test_sdk_usage.py new file mode 100644 index 00000000..6167186c --- /dev/null +++ b/packages/bedrock-messages/tests/test_sdk_usage.py @@ -0,0 +1,72 @@ +"""Tests for TESTING.md §3.27 — bedrock-messages.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import launchdarkly_ai_server.lifecycle as lifecycle_module +from launchdarkly_ai_bedrock_messages import ( + bedrock_messages, + create_bedrock_messages_handler, +) +from launchdarkly_ai_bedrock_messages._version import PACKAGE_NAME +from launchdarkly_ai_bedrock_messages._version import __version__ as PACKAGE_VERSION +from launchdarkly_ai_server import SDK_INFO_CONTEXT, __version__, init_client, shutdown + +CONTEXT = {"kind": "user", "key": "user-1"} + + +@pytest.fixture +async def client() -> Any: + await shutdown() + installed = MagicMock() + installed.variation = AsyncMock(return_value=None) + installed.flush = AsyncMock() + installed.close = AsyncMock() + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + await init_client(client=installed) + installed.track.reset_mock() + yield installed + await shutdown() + + +@pytest.mark.parametrize( + ("helper", "call"), + [ + ( + "bedrock-messages.bedrockMessages", + lambda: bedrock_messages("k", "q", CONTEXT), + ), + ( + "bedrock-messages.createBedrockMessagesHandler", + create_bedrock_messages_handler, + ), + ], +) +async def test_helper_sends_one_usage_event( + client: MagicMock, helper: str, call: Any +) -> None: + try: + value = call() + if hasattr(value, "__await__"): + await value + elif hasattr(value, "invoke"): + await value.invoke("q") + except Exception: + pass + calls = [ + entry + for entry in client.track.call_args_list + if entry.args[0] == "$ld:ai:sdk:usage" + ] + assert [entry.args[2].get("helper") for entry in calls] == [helper] + assert calls[0].args[1] == SDK_INFO_CONTEXT + assert calls[0].args[2] == { + "aiSdkName": "launchdarkly-ai-server", + "aiSdkVersion": __version__, + "aiSdkLanguage": "python", + "helper": helper, + "helperPackageName": PACKAGE_NAME, + "helperPackageVersion": PACKAGE_VERSION, + } diff --git a/pyproject.toml b/pyproject.toml index dc8370d5..7b742258 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ pythonpath = ["."] [tool.mypy] strict = true python_version = "3.12" +exclude = "(^|/)(tests|examples)/" [tool.ruff] target-version = "py312" @@ -61,6 +62,8 @@ ignore = [ known-first-party = [ "launchdarkly_ai_python", "launchdarkly_ai_server", + "launchdarkly_ai_bedrock_agents", + "launchdarkly_ai_bedrock_messages", "launchdarkly_ai_claude_agents", "launchdarkly_ai_claude_messages", "launchdarkly_ai_langchain_agents", diff --git a/release-please-config.json b/release-please-config.json index 90cd5c87..4db52da8 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -144,6 +144,28 @@ "src/launchdarkly_ai_opencode_agents/_version.py" ], "component": "launchdarkly-ai-opencode-agents" + }, + "packages/bedrock-agents": { + "release-type": "python", + "versioning": "default", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "include-v-in-tag": false, + "extra-files": [ + "src/launchdarkly_ai_bedrock_agents/_version.py" + ], + "component": "launchdarkly-ai-bedrock-agents" + }, + "packages/bedrock-messages": { + "release-type": "python", + "versioning": "default", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "include-v-in-tag": false, + "extra-files": [ + "src/launchdarkly_ai_bedrock_messages/_version.py" + ], + "component": "launchdarkly-ai-bedrock-messages" } }, "changelog-sections": [ diff --git a/tests/test_cross_handler_parity.py b/tests/test_cross_handler_parity.py index 7f3e2387..ed7f6541 100644 --- a/tests/test_cross_handler_parity.py +++ b/tests/test_cross_handler_parity.py @@ -1,11 +1,11 @@ -"""Cross-handler invariants: the six handlers must agree with each other. +"""Cross-handler invariants: all handlers must agree with each other. -Every handler package tests its own spans. Nothing tested that the six agree, and that is exactly +Every handler package tests its own spans. Nothing tested that they agree, and that is exactly how they drifted apart: each was correct on its own terms while a single run emitted `chat` spans that disagreed about what a finish reason or a cached token was. These tests are the oracle for that. They live outside the packages because no package can own an -invariant about all six. +invariant about every handler. There are two kinds of check here. @@ -44,6 +44,8 @@ "opencode-agents": "launchdarkly_ai_opencode_agents.spans", "vercel-messages": "launchdarkly_ai_vercel_messages.spans", "vercel-agents": "launchdarkly_ai_vercel_agents.spans", + "bedrock-messages": "launchdarkly_ai_bedrock_messages.spans", + "bedrock-agents": "launchdarkly_ai_bedrock_agents.spans", } #: `claude-agents` builds its `chat` span inside an inference tracker rather than in a standalone @@ -484,6 +486,7 @@ def test_the_carrier_stays_behind_the_capture_gate(self) -> None: #: The handlers whose streaming path dispatches tools inline, in the generator itself. Each holds the #: open `execute_tool` span in a local, so each needs that local in its `finally`. INLINE_TOOL_LOOP_HANDLERS: dict[str, str] = { + "bedrock-messages": "launchdarkly_ai_bedrock_messages.handler", "claude-messages": "launchdarkly_ai_claude_messages.handler", "openai-messages": "launchdarkly_ai_openai_messages.handler", "langchain-messages": "launchdarkly_ai_langchain_messages.handler", @@ -492,6 +495,7 @@ def test_the_carrier_stays_behind_the_capture_gate(self) -> None: #: The handlers that dispatch tools through the vendor's own hook or callback object. The open spans #: live in that object, so the same duty is discharged by an `abandon_open_spans`-style method. HOOK_BASED_TOOL_HANDLERS: dict[str, str] = { + "bedrock-agents": "launchdarkly_ai_bedrock_agents.handler", "claude-agents": "launchdarkly_ai_claude_agents.handler", "openai-agents": "launchdarkly_ai_openai_agents.handler", "langchain-agents": "launchdarkly_ai_langchain_agents.handler", @@ -589,12 +593,12 @@ class TestStreamingTeardownClosesToolSpans: `except Exception` does not see a `CancelledError` or a `GeneratorExit`, so the tool loop's own handler never runs for those, and the streaming `finally` is the only code left that can end the - span. Four of the six handlers once held the open tool span in a local that `finally` never read, + span. Several handlers once held the open tool span in a local that `finally` never read, which exported a closed parent above a child that never arrived. Structural rather than behavioural on purpose: the leak is a property of which variables the teardown reads, and a behavioural test would need a cancellable tool per handler to say the same - thing six times. + thing for every handler. """ @pytest.mark.parametrize("name", sorted(INLINE_TOOL_LOOP_HANDLERS)) diff --git a/uv.lock b/uv.lock index 5a267fc0..5f2e8ee5 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,8 @@ resolution-markers = [ [manifest] members = [ + "launchdarkly-ai-bedrock-agents", + "launchdarkly-ai-bedrock-messages", "launchdarkly-ai-claude-agents", "launchdarkly-ai-claude-messages", "launchdarkly-ai-langchain-agents", @@ -68,6 +70,51 @@ openai = [ { name = "openai" }, ] +[[package]] +name = "aioboto3" +version = "15.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiobotocore", extra = ["boto3"] }, + { name = "aiofiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069, upload-time = "2025-10-30T13:37:16.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913, upload-time = "2025-10-30T13:37:14.549Z" }, +] + +[[package]] +name = "aiobotocore" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/94/2e4ec48cf1abb89971cb2612d86f979a6240520f0a659b53a43116d344dc/aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc", size = 120560, upload-time = "2025-10-28T22:33:21.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039, upload-time = "2025-10-28T22:33:19.949Z" }, +] + +[package.optional-dependencies] +boto3 = [ + { name = "boto3" }, +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.7.1" @@ -219,6 +266,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" }, ] +[[package]] +name = "aioitertools" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -324,30 +380,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.101" +version = "1.40.61" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/ef/096f1520a4b0cbc794348fcf77ada637e5f98145c3453f219d678c3a0798/boto3-1.43.101.tar.gz", hash = "sha256:49f3eb750f70e050df9929a7e9392e67896c97d7d0a448f13ed3354c634268bd", size = 112635, upload-time = "2026-09-23T19:23:29.553Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535, upload-time = "2025-10-28T19:26:57.247Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/73/8dd65374f88b1b2a33656d9c808dc36aef0cb4a22c74d618ba6fe0092cd2/boto3-1.43.101-py3-none-any.whl", hash = "sha256:8a899b0ea94df3f2fab6d0c69caf2791f2971449696834374d8e88ced01c7ef3", size = 140041, upload-time = "2026-09-23T19:23:27.61Z" }, + { url = "https://files.pythonhosted.org/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321, upload-time = "2025-10-28T19:26:55.007Z" }, ] [[package]] name = "botocore" -version = "1.43.101" +version = "1.40.61" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/12/e90cc51bd65ecdcd0eedcd522d3c9f102b1d2c601f39f1f1c256695d63a3/botocore-1.43.101.tar.gz", hash = "sha256:3bc67fb55046e1e05ce5f2bd0171f37bef1cf54161786ef04ff338614d98169e", size = 16202504, upload-time = "2026-09-23T19:23:24.49Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956, upload-time = "2025-10-28T19:26:46.108Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/0d/6679253333d6ba74b8ad7077560687096629255ef526e106bb3accceffcc/botocore-1.43.101-py3-none-any.whl", hash = "sha256:f380237ffecc3f887265cd09c4d7e9c8e8dd9ba6162af83b1fc9e5d24622e461", size = 15897867, upload-time = "2026-09-23T19:23:21.643Z" }, + { url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload-time = "2025-10-28T19:26:42.15Z" }, ] [[package]] @@ -822,19 +878,6 @@ 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 = "2025-04-24T03:35:24.344Z" }, ] -[[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-03T11:45:09.509Z" } -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-03T11:44:59.164Z" }, -] - [[package]] name = "hf-xet" version = "1.7.0" @@ -859,15 +902,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/cd/072313585f74fe9d441e2eb5e0a4703c30586cd709810ea369675f61b74e/hf_xet-1.7.0-cp38-abi3-win_arm64.whl", hash = "sha256:acc3851cf2576a8fb2ae926da863f4efabe21303cf292e9a44332802ab0dcc6a", size = 3662436, upload-time = "2026-10-06T20:18:42.205Z" }, ] -[[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-06-23T18:34:46.667Z" } -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-06-23T18:34:45.472Z" }, -] - [[package]] name = "httpcore" version = "1.0.9" @@ -909,11 +943,6 @@ 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 = "2024-12-06T15:37:21.509Z" }, ] -[package.optional-dependencies] -http2 = [ - { name = "h2" }, -] - [[package]] name = "httpx-sse" version = "0.4.3" @@ -969,15 +998,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/16/963096d224b80909432dc16561a615fd33d2d13beef3ce4c63fa25e40867/huggingface_hub-1.33.0-py3-none-any.whl", hash = "sha256:04e434b06e100eddbce9a6e817d72693a7884b10a79bd67ab48080d5c07eb899", size = 846435, upload-time = "2026-09-24T09:49:28.059Z" }, ] -[[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 = "2025-01-22T21:41:49.302Z" } -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 = "2025-01-22T21:41:47.295Z" }, -] - [[package]] name = "identify" version = "2.6.19" @@ -1170,7 +1190,7 @@ wheels = [ [[package]] name = "langchain-aws" -version = "1.7.9" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, @@ -1178,9 +1198,9 @@ dependencies = [ { name = "numpy" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/5f/f9e5f406ce7f079c536f1ae82ddc6e20d5b93689125ce355aa993221ef66/langchain_aws-1.7.9.tar.gz", hash = "sha256:2af21da1af458c6e7d43e58d26978711e15b81a6f9916da38d882d4ca7b58213", size = 779952, upload-time = "2026-09-22T01:04:08.811Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/1d/bb306951b1c394b7a27effb8eb6c9ee65dd77fcc4be7c20f76e3299a9e1e/langchain_aws-1.1.0.tar.gz", hash = "sha256:1e2f8570328eae4907c3cf7e900dc68d8034ddc865d9dc96823c9f9d8cccb901", size = 393899, upload-time = "2025-11-24T14:35:24.216Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/67/ec05afc5b9d3e0e1671a372d244390d566d735e1d74543149036051b2f3e/langchain_aws-1.7.9-py3-none-any.whl", hash = "sha256:a0803d4ac4db8481e044e6814dd5cc1572a3e9a238694e64c7959492e7abbe13", size = 242669, upload-time = "2026-09-22T01:04:07.464Z" }, + { url = "https://files.pythonhosted.org/packages/26/33/91b8d2a7570657b371382b45054142c54165a51706990a5c1b4cc40c0e9a/langchain_aws-1.1.0-py3-none-any.whl", hash = "sha256:8ec074615b42839e035354063717374c32c63f5028ef5221ba073fd5f3ef5e37", size = 152432, upload-time = "2025-11-24T14:35:23.004Z" }, ] [[package]] @@ -1314,6 +1334,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/fb/dc9f70b5581371fba7b01ba0704fe859bfcd12c4dad82b42ec12bd4160b1/langsmith-0.9.3-py3-none-any.whl", hash = "sha256:aac85686868a7e61d77858b880230e510583c61ea48ac0a197e6ceff64ffb22b", size = 619927, upload-time = "2026-06-26T15:56:28.301Z" }, ] +[[package]] +name = "launchdarkly-ai-bedrock-agents" +version = "0.0.1" +source = { editable = "packages/bedrock-agents" } +dependencies = [ + { name = "boto3" }, + { name = "launchdarkly-ai-server" }, + { name = "opentelemetry-api" }, + { name = "strands-agents" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3", specifier = ">=1.40" }, + { name = "launchdarkly-ai-server", editable = "packages/client" }, + { name = "opentelemetry-api", specifier = ">=1.25" }, + { name = "strands-agents", specifier = ">=1.0" }, +] + +[[package]] +name = "launchdarkly-ai-bedrock-messages" +version = "0.0.1" +source = { editable = "packages/bedrock-messages" } +dependencies = [ + { name = "aioboto3" }, + { name = "boto3" }, + { name = "launchdarkly-ai-server" }, + { name = "opentelemetry-api" }, +] + +[package.metadata] +requires-dist = [ + { name = "aioboto3", specifier = ">=15.0" }, + { name = "boto3", specifier = ">=1.40" }, + { name = "launchdarkly-ai-server", editable = "packages/client" }, + { name = "opentelemetry-api", specifier = ">=1.25" }, +] + [[package]] name = "launchdarkly-ai-claude-agents" version = "0.2.5" @@ -1640,38 +1698,32 @@ wheels = [ [[package]] name = "litellm" -version = "1.106.2" +version = "1.97.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, - { name = "boto3" }, { name = "click" }, { name = "fastuuid" }, - { name = "filelock" }, - { name = "httpx", extra = ["http2"] }, - { name = "huggingface-hub" }, + { name = "httpx" }, { name = "importlib-metadata" }, { name = "jinja2" }, { name = "jsonschema" }, { name = "openai" }, - { name = "packaging" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, - { name = "pyyaml" }, { name = "tiktoken" }, { name = "tokenizers" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/4b/0f42182e4a3f957e8b5d8bdc2522779092da973bef7a3492c8e6a3409764/litellm-1.106.2.tar.gz", hash = "sha256:9f0e770cb007c8c6e78917ad7b5c4f059f7bdc4a3606f854831780ea53fd9e2d", size = 21431422, upload-time = "2026-10-21T10:44:51.337Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/08/db78a9f53e5688ad0f9e50d5c3bfe616e445aac120eafcca907f04634e48/litellm-1.97.0.tar.gz", hash = "sha256:6f7ce326a2e5385ef850e0b0768d41f502ec79278860090a838511cea067b067", size = 17762282, upload-time = "2026-08-16T00:05:44.724Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/47/1038af80d9831972fae8712a890c30252f51c514c092d83e0f1330bb1fa1/litellm-1.106.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bf6f13445bb59d8d02131bbc1fb57161d1ae629bec65dc66f9add4c09fb6994a", size = 36283395, upload-time = "2026-10-21T10:44:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/51/fc/15e44b2a222dfda71e0aff107fccfb49b06a4695fae0eb6867aff84f7e1d/litellm-1.106.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:cc778d01ed65a8dd57d5211d581ec237e06df239a09c3d573ace33e0feea76c9", size = 35263059, upload-time = "2026-10-21T10:44:32.664Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a8/f21d7fd8f7ffa6ebacefbedfb6268430e2b55ce9b2f33a36ac0295ccd500/litellm-1.106.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:34bfdc26c2c075726eda59ec0675e2a8a67b6e59a03980def7ef7651b849141a", size = 35827639, upload-time = "2026-10-21T10:44:35.553Z" }, - { url = "https://files.pythonhosted.org/packages/6b/30/4a38c22181ef7169b6c044b8294f2e5b06236aeeccfbe57c2f0e85a51bfe/litellm-1.106.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb1a2a1e55a4a8350ae2167525dc0d06f59b078cd685fb3e0f5419f02da5420e", size = 36789555, upload-time = "2026-10-21T10:44:38.879Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/e7ca210ad574f5321c162f2b27f8674c260e0857323a9991c4d3765ce8f1/litellm-1.106.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ce18971e2bb2aa58ef8efae1cd12da1248e1c2e5ae3cf5903bfb9d563bb34d9d", size = 35914468, upload-time = "2026-10-21T10:44:42.268Z" }, - { url = "https://files.pythonhosted.org/packages/22/03/95518dfb58e29c45ab40a7bc3d1d76509d09011172c380145fc9e900a214/litellm-1.106.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:32e567ee53c812e36f79158ec280c8850973f84a9e5fdef50d31ca09306d148c", size = 36901860, upload-time = "2026-10-21T10:44:45.807Z" }, - { url = "https://files.pythonhosted.org/packages/48/7a/a17de89184b5365980c3eb7d58176ae5a7e34538e56b942c833ff7f523b9/litellm-1.106.2-cp310-abi3-win_amd64.whl", hash = "sha256:836ab19110504ad7ba71d8ce73af6b9d1cf1dc6ffda2b49ebc1a51e348f754bc", size = 37619702, upload-time = "2026-10-21T10:44:48.564Z" }, + { url = "https://files.pythonhosted.org/packages/35/d4/a04f8bda468fb25a0a8a392a6922c9ecf6c122ad9bf9e2f69ebd173e1cac/litellm-1.97.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ff401dd5d66f54b9b474f0652c419fb7bf883fbf5ca64c0bc363acdc98b758b5", size = 24235645, upload-time = "2026-08-16T00:05:20.055Z" }, + { url = "https://files.pythonhosted.org/packages/f1/51/a055bf5df38112f07970937d95416b22379ee84664b739bfe87a8292e098/litellm-1.97.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:2983b40ed5d8b1bcbbfbc0d66fefa21b04db91b87c05dd680ae77cba74e561ef", size = 23893493, upload-time = "2026-08-16T00:05:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/14/28/b1cf429493aec5260ac2737dd82f6200c729fd26b336dc21cf001cfbcd8a/litellm-1.97.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e3a1f70d693716b4e8a8108f0a464b0e2c555e274ddbb8ef89c4c59c80be14ed", size = 24031743, upload-time = "2026-08-16T00:05:27.138Z" }, + { url = "https://files.pythonhosted.org/packages/08/4a/22c49e8bd068bfdab0daba235cc2d2752d76d855ccb8f6dafe7dabf01ca4/litellm-1.97.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5b56dce7df44a6a9e6caf5379de2578a8cb82831ceabd3d71cc99b370a1015e7", size = 24397362, upload-time = "2026-08-16T00:05:30.89Z" }, + { url = "https://files.pythonhosted.org/packages/0a/22/f60558230969ac7d860bd93aff6bf6a69bcd8a3ef4f35eea211174ef4e81/litellm-1.97.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6b360ddc3162c2ed39b64d3f9957a7af70cd9c60cf71f7a4dfa355a0bf05bebc", size = 24107078, upload-time = "2026-08-16T00:05:34.463Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e4/6c74ff4b188d9399a036d41e86c4c616d95ce5fd9d9b3c42646bd02f270e/litellm-1.97.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3c4f1dd45e14127f2303769a7ae79482697823e329ae503513d014ceea4dd704", size = 24493640, upload-time = "2026-08-16T00:05:37.85Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/1475c4ecdf43221ab8ee8d0fcc6b469c26d0d80913d0773e18de81cf18f1/litellm-1.97.0-cp310-abi3-win_amd64.whl", hash = "sha256:dce3377207234fc5c5b275a5e234ba056a5051fd178ae8e9a6aeb5d056f12095", size = 24289278, upload-time = "2026-08-16T00:05:41.441Z" }, ] [[package]] @@ -1823,140 +1875,140 @@ wheels = [ [[package]] name = "multidict" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/79/84ddb5ba16c4eb2c69c71db76ae3c579fe546e511f7170c7e27eedbab7c1/multidict-7.1.0.tar.gz", hash = "sha256:61a4e5d81b8d4e4ad61964b230129e7a2b914793d96289029078fc9009f074ec", size = 362341, upload-time = "2026-10-09T20:31:38.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e1/215e7df354e2907f3af9d910c8136653cfec94796012a776359bc802a326/multidict-7.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ccfb950359a80de0fcd2030ad60ac1b1a861462de3e2ef746697c9256659af21", size = 165426, upload-time = "2026-10-09T13:59:50.564Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ec/461ba588b308ada2cd16907d6ea425ca4d417596b88c12814ad9a0bb7325/multidict-7.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:939d8cd2d8c35e3956f6bc858390b6ccb611e6152b4920d64ab5e98f3fcf39e4", size = 95943, upload-time = "2026-10-09T13:59:52.381Z" }, - { url = "https://files.pythonhosted.org/packages/c9/84/31444ef07ec13a33c42c2772d986129e137e69c4beb89ca64f2138988145/multidict-7.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f8e95c95039eab6a2dad8c83c38ab87fc5431d28849e0c8a7e2a4e70ba38710d", size = 98294, upload-time = "2026-10-09T13:59:54.066Z" }, - { url = "https://files.pythonhosted.org/packages/dc/10/aca13806d73d88b5b01e35e828e23a346cb1abad710a49dff0507702efc9/multidict-7.1.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05d12b4bac53abe0c65f3163af2b45894e2e1c0cc55493ac784d52a350047d88", size = 453301, upload-time = "2026-10-09T13:59:55.397Z" }, - { url = "https://files.pythonhosted.org/packages/96/8c/382d771bfb3a9282d98332d0e1f27fd1f8b4ae0e7175bd7e008ebf934900/multidict-7.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e79ed92b1dece6bb57e9b46effd74d7a5d3d00187c85466d880ed184239a698", size = 478570, upload-time = "2026-10-09T13:59:56.899Z" }, - { url = "https://files.pythonhosted.org/packages/52/9c/e81b0c92449da3a1950a575c520d957d7be618777d170717a71e00558d7f/multidict-7.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fab380fcff8b3555eb2bd04304fa4330909a771a9a9b0dc07666cfc23148a711", size = 443699, upload-time = "2026-10-09T13:59:58.465Z" }, - { url = "https://files.pythonhosted.org/packages/63/72/f8f5fee6960d1b580a7b046c7c5abccf67aa26fa5647927a91c9c835c2f8/multidict-7.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b5c41e44da74383c924cc5d75ef0a268f301d69305b3c42bd17af685d55e412", size = 497332, upload-time = "2026-10-09T14:08:01.88Z" }, - { url = "https://files.pythonhosted.org/packages/e2/92/a25d7db3ca451b588e743e067ee80f2b475edf6467a56908454faf6a714c/multidict-7.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dbaa7f7c2f0ca8578895fc61fb8c8e50ebb405dad8982f92f4343285c7a3fda", size = 512457, upload-time = "2026-10-09T14:08:25.465Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f3/374c0ab122bb98b1a62a8742b1e3f59563e4d609942005a4041861a0df67/multidict-7.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed6b7705d49dd07e5e0dd5f5c873fc44047e92d714299b13245b5fecac49d01", size = 483000, upload-time = "2026-10-09T14:08:27.132Z" }, - { url = "https://files.pythonhosted.org/packages/46/1f/01c8522859771dc3cee840d5852233fe84c91aa982a1cd8ad594306d25cc/multidict-7.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53daa47dd176db64bb35170e3d5d0ae2388c060121201883696278f055a0e70c", size = 430746, upload-time = "2026-10-09T14:08:28.715Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f3/af90affc8cca6b4ee59b2ec354a20839005e829d14d155451009810ef1ce/multidict-7.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:542429c796430de924d03b68a6173bb6d79d5c4967d4e9a18de3e501cad55593", size = 466859, upload-time = "2026-10-09T20:27:24.139Z" }, - { url = "https://files.pythonhosted.org/packages/f8/97/1b6762f37f6331449e164af9d5500f0abb0f23670e81ba543441e0a6be1d/multidict-7.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c44ca6d3cdf4cfcbcd4f928fdcbe87af5fd7319f6ad4169617b7fd6b4527c33c", size = 441952, upload-time = "2026-10-09T20:27:26.559Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d2/4908177fbf22438799c04ba11a2853a99c69d028fccefe61f19e68caba0c/multidict-7.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eb0228c809b2e7eb47921876050af0bc4214b351bad8d8112f70b6ed4288763c", size = 452810, upload-time = "2026-10-09T20:27:28.347Z" }, - { url = "https://files.pythonhosted.org/packages/8d/05/5031f44f680ec54fc182c4d71c9ab7c07216983946aa64ce6fdd56a52692/multidict-7.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6ad60de1f4c702448fc8f1449f05e810f6b7957c08a5b3950c8a792dfb13b50a", size = 483934, upload-time = "2026-10-09T20:27:30.14Z" }, - { url = "https://files.pythonhosted.org/packages/da/3b/9b21d107dbe96fa7e6966ff9e5e10b9ac0d2d1c2cf1633098e4c700f865e/multidict-7.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f79def86aee67b5ba01b2565f1610f262bf88ae53c379f93e5fa29c50fe793be", size = 427042, upload-time = "2026-10-09T20:27:32.433Z" }, - { url = "https://files.pythonhosted.org/packages/53/ce/5b01b1041580072866b30e39c6380bff269e72fb5ca41a7f2fb828ede943/multidict-7.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:248dabb89b5aa90b2f7e43e045f048f7e5392ec77b6446d80853ba7117d7bbdf", size = 492543, upload-time = "2026-10-09T20:27:34.459Z" }, - { url = "https://files.pythonhosted.org/packages/1e/6f/6508a23fcc7b1122e4f18409d7900ffeb3cd020cd080fe98aba3eabf9486/multidict-7.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0747a83e7ae617793181a4763ee8b84863cec5c0bbbde70c4394e4c0276c36de", size = 475796, upload-time = "2026-10-09T20:27:36.289Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b6/ebb6433f4aa55ac1fea4bf11e860e99611640d07cc92d21cb3f35608de22/multidict-7.1.0-cp312-cp312-win32.whl", hash = "sha256:1df055e51fe7491120cc84f3362bd43db186be78d0e4c476acad45e435af9ffb", size = 81250, upload-time = "2026-10-09T20:27:38.229Z" }, - { url = "https://files.pythonhosted.org/packages/61/0a/11240e5e7d2e986e288f4a6090f569ad00225b7c2c513d4096b36643f9ab/multidict-7.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:10202ba98cfb3f7eb60da7ca87a2c458a69b7d0d6e4d4388cd6773ebbce89085", size = 93817, upload-time = "2026-10-09T20:27:39.739Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9b/a04ffd76db7cca2a60d347e839315e1ada63017c6c343fa90a23a5a55433/multidict-7.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0aa1ba3ff7cdda05a1242490612976b2ae1c90fc6200903ef8f53815dcb35c5d", size = 92312, upload-time = "2026-10-09T20:27:41.285Z" }, - { url = "https://files.pythonhosted.org/packages/60/34/22ee5f60d704e8edfe6e0a37ca1e2d1efe276560dc8f6f8caee35ea7a4f5/multidict-7.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:51d7f33be9a4a1a2801430846d72841deea0894eae8381a07e7d90e0f71b3c4b", size = 101454, upload-time = "2026-10-09T20:27:43.102Z" }, - { url = "https://files.pythonhosted.org/packages/3b/6d/7652943397171fd624de163d25fc6d2807852931c40c24776cfee3f857cc/multidict-7.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2a964dfeb2aba3663f0536c809aa1ff385f065e89fae57e883fb7edfb4067c2f", size = 95823, upload-time = "2026-10-09T20:27:44.912Z" }, - { url = "https://files.pythonhosted.org/packages/a2/57/717d1aa4e901f58a7e93b945ed741c860d950f456b75be47123fef507ac2/multidict-7.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d02cd23b5af182a49d635ee72be38053767711987a9fd82625b16b93828a0d8c", size = 97841, upload-time = "2026-10-09T20:27:46.616Z" }, - { url = "https://files.pythonhosted.org/packages/a8/74/d2d22d306225f3c53ea5a682abb8a43bc30057d6c78b5c94a74035450bfd/multidict-7.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0179698c3c913eb64f32397083747fad20ed0f0a2b7469a08cd1a8a95d14d90e", size = 164661, upload-time = "2026-10-09T20:27:48.495Z" }, - { url = "https://files.pythonhosted.org/packages/55/f4/1e63fea41ba86768e18dbc1743e6780f75ad32d7a45e418740c6fb64589e/multidict-7.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ccf98ee859fe29f874ddd8e637f14ba59108a333492b521acb885a9095244a9c", size = 95489, upload-time = "2026-10-09T20:27:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/8c/55/477c351b21b34fe948ffffa8b64cd0c246efd998fed3de922b217e632e59/multidict-7.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5fa1484f74d011addf2e5f5a0378ec41521989839a05d6051d8067d8ce732423", size = 97931, upload-time = "2026-10-09T20:27:51.598Z" }, - { url = "https://files.pythonhosted.org/packages/cc/dd/288508d7deb9489dd7c8e0b172d62dc1da8f3cb24877293ead42e6cc2047/multidict-7.1.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1fed3d721f75c25a9fcdd0e362af53f4b20acbcdc63081112f85419ba0ce3444", size = 453816, upload-time = "2026-10-09T20:27:53.217Z" }, - { url = "https://files.pythonhosted.org/packages/60/ed/172447dd09f06111f69d2cf22a018020b34a93a39ddf72cddfdb48323449/multidict-7.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df03e392cae1e05462918abbae06d6100f1e53f67db971ff0ac6c07d9edf7321", size = 477779, upload-time = "2026-10-09T20:27:54.972Z" }, - { url = "https://files.pythonhosted.org/packages/e4/7d/e5b7755e84611ee846f0dc830cb1363ce10764b29247a01b857722fda653/multidict-7.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:379f477b98a1e9a77ddc3ccaa8c709d3fb4a288ff54b96e171e637b55b4adbae", size = 441299, upload-time = "2026-10-09T20:27:56.901Z" }, - { url = "https://files.pythonhosted.org/packages/1b/d6/e9c93da1644491610f09258349283421a279fdb3b383929b4d963698691c/multidict-7.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f586bc8a084a3671ddcae9e5fbd3228a596bfb63d9f0380f153f9a65b69f08", size = 496193, upload-time = "2026-10-09T20:27:58.788Z" }, - { url = "https://files.pythonhosted.org/packages/22/49/7fe19efed1b1c73e2f6ae65eba4e6528eeb26e10c970dc0bb0fc009a2aae/multidict-7.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e7f70d912a589e30290ed926f90ddbc3160998359cbad7c9ede1bcee481748c", size = 511469, upload-time = "2026-10-09T20:28:00.689Z" }, - { url = "https://files.pythonhosted.org/packages/4d/2e/7b708a72001323dc6b2930834d63e355870ea5b1ee8d1450e0fe1a24752b/multidict-7.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5129cc1f5fec6888e2db0be936dab67242e32c738811c8769aeea93aab4257a8", size = 481377, upload-time = "2026-10-09T20:28:03.011Z" }, - { url = "https://files.pythonhosted.org/packages/8b/60/3d23e7d2ebc7e7e6b1b205de0d5c2ce0652a9b2edef65eea3528758b5699/multidict-7.1.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1401caec21fd7f002e79ab6806bbfd1f54bb3de6d5e12bd91c6685dce16ad2be", size = 429992, upload-time = "2026-10-09T20:28:04.846Z" }, - { url = "https://files.pythonhosted.org/packages/f6/e6/8e3bef78ea1929a3f2c395aa292799d4effd6ce1b5cfce42c1986a33e2b5/multidict-7.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c81062e947f4b5a624135a843f6ac4b3c7fe6508300c9fb27347f022ba0c513d", size = 465635, upload-time = "2026-10-09T20:28:06.527Z" }, - { url = "https://files.pythonhosted.org/packages/44/6a/55fe5dd90c0e9ce60f5c4fa8ace27eeb1190d9cbb151929b04412f0c673a/multidict-7.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c53be0dd676484a660acc56e4f1cd0dd74bc1255d12fa285e86a3fa9d5f22bf9", size = 441693, upload-time = "2026-10-09T20:28:08.443Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a9/628913f71537dce7dbd6c4ee1ae5019976264427c55354ecc593b4b921b9/multidict-7.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:41e0c3350d08994ee8640c39884e16514e282f70ba40f5b2299582509a327774", size = 453808, upload-time = "2026-10-09T20:28:10.871Z" }, - { url = "https://files.pythonhosted.org/packages/25/d0/3ae3af653b5776d045f460582006d0b4d7dd41b078b3ab2e874bf4d1e22f/multidict-7.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:abeec7a89d698aa1c9b4c36bd5e3c746faef0867076e6a2ca27fa5077c4ece26", size = 483160, upload-time = "2026-10-09T20:28:12.892Z" }, - { url = "https://files.pythonhosted.org/packages/56/3e/2c13e111b9f4c6216aee0ac57ac39c53db7f97e42bb8490e60a06eaca352/multidict-7.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:23f6d325241b0db006ca2841309ed17622137e134930a740a8f1331ec4404791", size = 425896, upload-time = "2026-10-09T20:28:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/45/13/15b4d614cf0d6838a7d89eb14f4820fdecfd1b7f529061d172a674912d41/multidict-7.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:034b0dc1b7fb8279599c5d8563f86abb4d2454735b06544ecab23c54572ad2bd", size = 491674, upload-time = "2026-10-09T20:28:16.508Z" }, - { url = "https://files.pythonhosted.org/packages/4f/97/3a6f8c75f12a607ff047e3db9a45dff6a558b1e31b89b2f0f27da0fedb6f/multidict-7.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c8074ad4d67067c87bd0663dfda654f786336078c8fd7d2f6c1aa41de8494cc", size = 473989, upload-time = "2026-10-09T20:28:18.284Z" }, - { url = "https://files.pythonhosted.org/packages/3f/20/5e7726631e50b6afd26324a234851a06104b8ee30f01bc209140788b76c6/multidict-7.1.0-cp313-cp313-win32.whl", hash = "sha256:7b25c335fc53acf29d4d21dbc19fe39d2824201cdda0448623152cc5917bd259", size = 80952, upload-time = "2026-10-09T20:28:20.044Z" }, - { url = "https://files.pythonhosted.org/packages/4f/19/b26bdbef5bf5071ea5a3ba37c963af187fddb8b0a925bd39c0c5dd0ad04a/multidict-7.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:7de54b49e6da811b0321e412d14efdaa1ee0c0b6609296ea5b9022bc5b2bd843", size = 93554, upload-time = "2026-10-09T20:28:21.657Z" }, - { url = "https://files.pythonhosted.org/packages/35/d5/e9a8600d3868e3fdc4a4be1d15e594048508511c0398423df0ed18c11b5a/multidict-7.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:aeba2c750102051aa51e087c2ccbc79f2724a41c94168f8731e36f54c453551a", size = 92039, upload-time = "2026-10-09T20:28:23.464Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a7/c9f5c08348a6f903143b631546e22becf1b704a1304b17f7e1b9850308d2/multidict-7.1.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:128ea4142f81a79d430f3d0eb55206093e5eda03a12abbc7b03c34748ff6116b", size = 100435, upload-time = "2026-10-09T20:28:26.111Z" }, - { url = "https://files.pythonhosted.org/packages/82/60/92fe617008c74cc019483b1c59202c48738c6f637ad5b12b36f01e21db43/multidict-7.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:439a19f7fbbff232ce96682c57e27030b8ac3a4b8121484c94f04bf99d08bfff", size = 95267, upload-time = "2026-10-09T20:28:27.856Z" }, - { url = "https://files.pythonhosted.org/packages/17/78/82182f311d673f17de15bc7f7465c7ea5cbd2987ca3a6bf6546fa721e9d8/multidict-7.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8090c35199d6b7bc6426bb8bdaf341e64f295cc2624a1fda7860c0837f1acc03", size = 97233, upload-time = "2026-10-09T20:28:29.817Z" }, - { url = "https://files.pythonhosted.org/packages/63/25/af4e482d053fd73b4b1d60de3ff5578c6cb1d319d39d3f2a454d65409835/multidict-7.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b7cc5333fcbfb27327d12612ed72322f221b61c2b69deb1155078c964f86e1a1", size = 163070, upload-time = "2026-10-09T20:28:31.495Z" }, - { url = "https://files.pythonhosted.org/packages/df/d5/eaed52e199451dac445305fb2631d3f4e7ac9536aeef01f23622a1d93f90/multidict-7.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b0e0040b0d8dd89bd0af9ab18901981e344ffba68bb30b8eabb4eab6c303279b", size = 94582, upload-time = "2026-10-09T20:28:33.263Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e3/ff58ecad5161baa98dec05716e2745449446f2424a9c727464bf452f7fd1/multidict-7.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:33376418ab2846b931a72b36cfa16810befc4f49485d0b3f4dc054a4d6d00038", size = 97406, upload-time = "2026-10-09T20:28:34.981Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a2/ae4eadf02d4bc035baa7cadf5548ca4fa441517193f76ce1aeb5ed276ae3/multidict-7.1.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1101aea5c3eb1d26e090b931c693488af0db9f3d52e68be8d4cdd807dad9841d", size = 449908, upload-time = "2026-10-09T20:28:36.659Z" }, - { url = "https://files.pythonhosted.org/packages/91/55/bd5101ef760d1af4c3f246330b29b48ee22bfdd5a83150b713dd93b431e2/multidict-7.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f996b19ac89e0dae65821ce65f788619e4286f78c62d005ecd3b75b5d9c0892b", size = 471605, upload-time = "2026-10-09T20:28:38.561Z" }, - { url = "https://files.pythonhosted.org/packages/4d/8f/3dea8a28b0416ab71d47c8ce274bb3cacedde2d9838647ac67cdaa3d48e6/multidict-7.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e0d91a4bcb59ac0d7af0d8e0da737332e1b7fe6831e53e47819b1b5349d431b2", size = 433523, upload-time = "2026-10-09T20:28:40.913Z" }, - { url = "https://files.pythonhosted.org/packages/24/97/805d2aa4f746cc43cf4b206f1b1bfe2566add95bbb145abd3d9cf61858bf/multidict-7.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e96d67914ddbf5466e4476a1cd7ff30a332cbab85ed895207acc3e58c979b6a7", size = 488277, upload-time = "2026-10-09T20:28:42.851Z" }, - { url = "https://files.pythonhosted.org/packages/dd/7b/eac247b7e76f6010071c7c2401da5255eeffd1b2ac981925e533413d21ec/multidict-7.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:170ba61761f59ab92afcc86ce5534a3f3d0b07c38b339b950a83213f22dd86ec", size = 501888, upload-time = "2026-10-09T20:28:44.75Z" }, - { url = "https://files.pythonhosted.org/packages/87/58/de62e27b09dd15756265765945f68f1c0d1e23eaba09e2e109fbb4112425/multidict-7.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33389fe084e5426d9fd85d7d9ca91a29cd0d88a83c7c96e411aca49a3f9967bc", size = 474883, upload-time = "2026-10-09T20:28:46.704Z" }, - { url = "https://files.pythonhosted.org/packages/21/fd/afb4e50ce3b44707b5ee39c6b5fa1573bdb50c4be660bb35b040362b5170/multidict-7.1.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77024596b9046572c4e90b34c1ff212346756dc48933f90c53cf6e233660788d", size = 424327, upload-time = "2026-10-09T20:28:48.681Z" }, - { url = "https://files.pythonhosted.org/packages/23/70/96c9abf933c4edae53b8a1c8d3f038120a3a60d363f16c6d94eee9b04851/multidict-7.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ff15531a376dc6f35984443fd1429e4b150c36ce27633e7cc52a9e5318546e20", size = 459255, upload-time = "2026-10-09T20:28:50.562Z" }, - { url = "https://files.pythonhosted.org/packages/24/37/dbc1dba26dc1c9e42aa07ee01908131a67fee0e79ebe5c1d0e7fc00aad55/multidict-7.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:544f2642a456fa264614e975d921540ee8c3b368b04d5aa1ddbec33241b13e08", size = 432476, upload-time = "2026-10-09T20:28:52.361Z" }, - { url = "https://files.pythonhosted.org/packages/9c/0a/eac70cc1461668bad8253a2da727670a56293eaba112aeb4079af8cd37d9/multidict-7.1.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08834fb8b20e1a985c70e8380a10940234b4162de62694458727330376e58b33", size = 450030, upload-time = "2026-10-09T20:28:54.252Z" }, - { url = "https://files.pythonhosted.org/packages/23/2d/eb40652ea74f96df859c7c5d38f9997f420b9dc404300dda0c5745327a6f/multidict-7.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b4908e17867930b7ac77f89a18dc67308c67c511f037d8580489be86fb585912", size = 476169, upload-time = "2026-10-09T20:28:56.819Z" }, - { url = "https://files.pythonhosted.org/packages/44/d0/7454ab8335bc4ec9f8abcc4c83a314bab060f22db6da8436b396e07ed21a/multidict-7.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9b24e1f93b9b586ec03bc7bea1bf021ec90bf2528c729195028a3ca1c266b3f9", size = 420459, upload-time = "2026-10-09T20:28:58.744Z" }, - { url = "https://files.pythonhosted.org/packages/60/7a/cedb46b287b720a2c4eda2448faf162612d0b718beb32581e35ccd9219b3/multidict-7.1.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5c93473d0d7cd9bbb370973a9679a62f381c7050d7dff4ad6aaa92e8650f5a79", size = 483071, upload-time = "2026-10-09T20:29:01.114Z" }, - { url = "https://files.pythonhosted.org/packages/9a/11/e7ded22b008546dd30ae8c979aa5ac3282cad98876f7d5bbe93c1f2409a0/multidict-7.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bf14cfcc30b097583d698a6e2b8b68c9bcffab277c485d481881958360c2938d", size = 467470, upload-time = "2026-10-09T20:29:03.388Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f3/0136144cb0b1731fd93475cea75d974a55009358d3bf61dd55e2709a3dca/multidict-7.1.0-cp314-cp314-win32.whl", hash = "sha256:86bc779a0896e59e4be30a5be5cd6eeffd0b40b6f0e75e730218736b7bfc6f5c", size = 80205, upload-time = "2026-10-09T20:29:05.736Z" }, - { url = "https://files.pythonhosted.org/packages/74/d9/a62a690d780febc171026d3e3c5700fc8387aa3e5f77cd04ada33d23d3f4/multidict-7.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c9fd50f636a8fa9cb6324cd3eac962fec2bc5bb432452a3b583583a1059acfc", size = 92923, upload-time = "2026-10-09T20:29:07.337Z" }, - { url = "https://files.pythonhosted.org/packages/84/08/eb7a1c34dc37c5f2c6aef52e6861e6f1cdfc6c685dcb72581eb218c00953/multidict-7.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:e6906aa4bc62cde2c8aeb8a99a7b4401b241e274ae7b11df67d863d61ab3d5de", size = 92851, upload-time = "2026-10-09T20:29:09.15Z" }, - { url = "https://files.pythonhosted.org/packages/ff/3a/019abd746e8fbed9edd74b81259f20b278c011cef918868506d9b5bd6566/multidict-7.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fabfdd4cf97db033196b51af46b8a681d4785c2a66347f2a5af1b4bbb1182629", size = 177429, upload-time = "2026-10-09T20:29:10.989Z" }, - { url = "https://files.pythonhosted.org/packages/f7/41/c8dda935231305d9b83c4a4cc5b5b9e323615328d4704f00ba2cf6d89916/multidict-7.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a177a0ee5cf19931dcaeb3f662bc562754cfa4f4ace2351d9da24a954ef7db94", size = 102003, upload-time = "2026-10-09T20:29:12.78Z" }, - { url = "https://files.pythonhosted.org/packages/af/23/3c3e79a222eaaa59a32648cec34709d848657be5339f96876075731e7f8e/multidict-7.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0ae91de396d5c4ac97cb24dbada3d5c91a51454781e0a70476b008f4e879e4f0", size = 104861, upload-time = "2026-10-09T20:29:14.591Z" }, - { url = "https://files.pythonhosted.org/packages/cd/5b/04637e7cea5729466fb89048520fde167c5404df1beedcc86f18aeabed7c/multidict-7.1.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:160bdb3520fdadcaa21e1b98aab2e011265070814ecab3804eb61674becbd400", size = 463221, upload-time = "2026-10-09T20:29:16.697Z" }, - { url = "https://files.pythonhosted.org/packages/2d/42/b121767de213a9774399c200cb877b19c2e1e5a0b0a6bc8407c7325fa37e/multidict-7.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c2144785e42527404bbd5cfd11981fee4abe59a22aded0e498eb711a831d3f3", size = 502241, upload-time = "2026-10-09T20:29:18.657Z" }, - { url = "https://files.pythonhosted.org/packages/47/0a/1cccd17ebf39776df7d8a6c99d066fc3ac1823d044cfe86a22ebbf42b841/multidict-7.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7d0b4fec6a8d02d7e95de5cfa913261820f1ce04bd4c0381924de0da523179b8", size = 457128, upload-time = "2026-10-09T20:29:20.589Z" }, - { url = "https://files.pythonhosted.org/packages/19/a9/0616d20dee16255f8737d9017288d9304dabf3695ac0071baca25d00c713/multidict-7.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:943a9bce22180ad0f4d32d1b402a0949a4ecfe5a1257b47f54a1b51981d81b86", size = 511842, upload-time = "2026-10-09T20:29:22.569Z" }, - { url = "https://files.pythonhosted.org/packages/91/68/5a406b82ecfeee50c097c05fd519c836a61815d81c4a9a8c523d90fb4b35/multidict-7.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f76ceb623f7ff50df46ac57e1587c479d87a5766319c4f43d0c0a5158896afab", size = 519852, upload-time = "2026-10-09T20:29:24.527Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/d42234f9d4f0e33885c70149112bf62aa4436045090c15cec0810ada0b2f/multidict-7.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98beff85392ce435b28a0971ec21cade61ce8be8b632c9d855475a28ef92d31a", size = 500322, upload-time = "2026-10-09T20:29:26.569Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c6/323e921a9812a6ea82978d4e1d9713e0b709023be037e6e4c14efe5472eb/multidict-7.1.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:71196ebb8d523148e5975396a444de02367f204b53b14e26794c96b2be0ed742", size = 444070, upload-time = "2026-10-09T20:29:28.52Z" }, - { url = "https://files.pythonhosted.org/packages/93/87/109b7170de2168294f3e562f9d10caaaf946628d614b5a57246279acc1a6/multidict-7.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:35534b366410a36bb3d6f788691e37a76e4d1da48326b0ada3e5032580dd76af", size = 484862, upload-time = "2026-10-09T20:29:30.504Z" }, - { url = "https://files.pythonhosted.org/packages/51/cf/7f1f63c9cf13f47036c0c64c607e6eee87c94076e3c6876abb6990ee62ad/multidict-7.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4674b12701c3fcbdf7f88b9e4479701c93bec5da9eb576140d5fcc0092990af", size = 457314, upload-time = "2026-10-09T20:29:32.6Z" }, - { url = "https://files.pythonhosted.org/packages/93/ce/7de8b4ee6a847cc951915c279fc388127ec32a7c14aa7a01c4ad692be575/multidict-7.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ead852a5e906a43fcb6784eeac480f6a67919a51d480c1f80d32ddf9d615475", size = 462672, upload-time = "2026-10-09T20:29:34.776Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/02dd089475983b1f5b8729319caa0b02fa3a60b527f27c86b2d53c24ca11/multidict-7.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:afe36ca503c2ffe30fb6df82b20389fa3c4035b5d65888a61310921cf3ae91c5", size = 498109, upload-time = "2026-10-09T20:29:36.763Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/b9bdc59aa8e3eaf1f9e5d2460bbe0c428a01250d44417f8ccf965a8f6fa6/multidict-7.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a5f0bebb10aae010d3c9ee3abaf83ab2069c718457aea09c15532355dd7e061f", size = 442260, upload-time = "2026-10-09T20:29:38.872Z" }, - { url = "https://files.pythonhosted.org/packages/fa/83/21b044885ab81bf5c03ec5c4c16aa449977e428c5c122e38969481cec1df/multidict-7.1.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b9d9b7d72975521434368fe8aed3f6b522060bf271adabaa5ca6c87c0c08e168", size = 498916, upload-time = "2026-10-09T20:29:41.091Z" }, - { url = "https://files.pythonhosted.org/packages/95/8b/31686730092e349ee2255fa630945ca344fc62e76f0b18d65b0f3dbb0ecf/multidict-7.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:274023bf952f849e0d05eba28a4c1f65f9796430d2b09ec16539386c0f76554c", size = 493525, upload-time = "2026-10-09T20:29:43.192Z" }, - { url = "https://files.pythonhosted.org/packages/c7/d0/c9fddcd7ac42b70a46ac9cf15e21eb527874164e14d418fcb50ce7190a4b/multidict-7.1.0-cp314-cp314t-win32.whl", hash = "sha256:7e0bfa161df365ba3c88899ee3b7c94755200967284bdedef8c1b8b43e2c0f2b", size = 86481, upload-time = "2026-10-09T20:29:45.188Z" }, - { url = "https://files.pythonhosted.org/packages/24/ab/ce727c06680e72b5d381caa8587f561cb6fea1bfab6ba20c877019768a6f/multidict-7.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:34a35be8fb82d37087e8176aba907b9459f03d0e293c80f574c6337a436f4eaa", size = 99676, upload-time = "2026-10-09T20:29:47.441Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e7/d116d7ce514d04d9bf63774ed55e8033e2ce15ab5655a597bb947c53dfb8/multidict-7.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:d7dd46a8fcd7653c09ebe67eae9d4cb6636c7a905d9cbaf587dabcbd4eca6013", size = 98726, upload-time = "2026-10-09T20:29:49.365Z" }, - { url = "https://files.pythonhosted.org/packages/28/42/963e2e38ffd79487b24775841cfe2abb85aa1fa08ee152afc4a9b64e1cdd/multidict-7.1.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:852c921217f330b3e81a822647ebadeae7e42cf503ec1992d0bfbc90121c09fb", size = 100450, upload-time = "2026-10-09T20:29:51.486Z" }, - { url = "https://files.pythonhosted.org/packages/b3/6e/fbc4721aa0eaea2dff3274ff44e94f60a89841bbb4cdaecd9faa4eb64aa7/multidict-7.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cbec738d2ad551c6f70955d7eec95e339380ee1564e2afe86bfee05fed52ceec", size = 95440, upload-time = "2026-10-09T20:29:53.929Z" }, - { url = "https://files.pythonhosted.org/packages/76/66/045aee749b599560d5348bb1bee5c9d61d8b12f31d16e0594778ef3472de/multidict-7.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a5f721a2437390ab69c10c6df5c142478d399af8dfb02e6d823cf2358e8a4748", size = 97496, upload-time = "2026-10-09T20:29:55.808Z" }, - { url = "https://files.pythonhosted.org/packages/97/79/f2916b81324d629eba363f760fba26cafef5cb437489c24969ada979508d/multidict-7.1.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:99cf27791129d37e191ff013bfc29bf6631c29edb21680c00978567b91fc5d6b", size = 163241, upload-time = "2026-10-09T20:29:57.813Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f6/2aab2b2d7bf2d69204bde0cdabfefe4df3d1954f3f156bfc53714917e757/multidict-7.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2ba6611fc93c4b169d0e0ea376ebf4b8a529933d1f5f2c2ec7d8f8b93ef58ec2", size = 94586, upload-time = "2026-10-09T20:30:00.201Z" }, - { url = "https://files.pythonhosted.org/packages/b6/83/cf690ab80a0db0f50b300a7141f45dbf1bbbb6b32457f74bb55f47d38f5c/multidict-7.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c45629c0049fbdef932dbe408ac2b271fdc8c7d9962ca31160f4a0fc3455fe4f", size = 97573, upload-time = "2026-10-09T20:30:02.675Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2e/951c1412c9803e8f87c8673305be1e448761c940cd867ccfdd3e6a551dfd/multidict-7.1.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cf606cfe3f67984b4064ac605d71e1eba12515fbabf5bd5a34a8952b8800dc66", size = 453476, upload-time = "2026-10-09T20:30:04.622Z" }, - { url = "https://files.pythonhosted.org/packages/a1/55/12f9f95fc65470bf481ad3d1c0e0fc20094720d0af1985119656f2b4be25/multidict-7.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50fdfcb03be719d9573597b095b1175d2e9d0b30d065791dfd9fca727c499442", size = 473078, upload-time = "2026-10-09T20:30:06.986Z" }, - { url = "https://files.pythonhosted.org/packages/87/31/ebe216194a68934de7053d582cde62c839bef4dbc4ae67e9bbccf80a61d2/multidict-7.1.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a8bba9d1f6db4ef2a6ebfc937a65d36e80e3aada00b382eaf56fea8f639322d5", size = 435633, upload-time = "2026-10-09T20:30:09.021Z" }, - { url = "https://files.pythonhosted.org/packages/46/e5/4aea764cd6a1d326d91f2bb92b1c8c39c3f5a8da624e7b20b88179dffb72/multidict-7.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46d4af0afc6eb9867b3ae50605787c80b868e2f52eac3801246034925fe578b8", size = 488656, upload-time = "2026-10-09T20:30:11.119Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f8/b1e5935233fa4b504b1a58b584fcdf03526635e4b5f268f716be9108a1a4/multidict-7.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5cc58ebb731200ddb64d55f1b345630fb5f7a8138cdbd242af9dce964a7cb03d", size = 502311, upload-time = "2026-10-09T20:30:13.396Z" }, - { url = "https://files.pythonhosted.org/packages/9a/8e/cde07147b6fc56b9ae0fe01d9c0bebd730b2be2c3052d3a5a117e13f561e/multidict-7.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b87ad54e8d4adeb0a1f04889504d6ec7f04fb02609220810f51f1b6c66bc1cc", size = 477470, upload-time = "2026-10-09T20:30:15.894Z" }, - { url = "https://files.pythonhosted.org/packages/68/a1/11ac1880eacddcf698aa227ba9387cda60128e38034eda182cf4585109ee/multidict-7.1.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f7e5dd83a615636b80182bdf446ece57ed61d5d51854acc5d9840631136d4e", size = 430088, upload-time = "2026-10-09T20:30:18.972Z" }, - { url = "https://files.pythonhosted.org/packages/6a/5a/09926de0ec910704507a21c4f1ad76026aab9a5657ced868c57bbbe9324b/multidict-7.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9bc5e7f843d14a167cdc26fe2d22f6f3aa2feb57919cf3ff034262a57d8d95d0", size = 460632, upload-time = "2026-10-09T20:30:21.45Z" }, - { url = "https://files.pythonhosted.org/packages/ed/36/cc51eb3ffb09f475326bbf868c6af2805fbbe9175122ce6d7a65aa06d6c1/multidict-7.1.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0631eb5f49f67de10bbdc3f64141326dbc62e8d319900966648381ce0845d8ca", size = 434056, upload-time = "2026-10-09T20:30:23.679Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f4/9e0e4dce595573eba1555495ac9ca6b5f7f6e14cf6c3a5cd87ce5bd33d6f/multidict-7.1.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:d1b1b32f3c32f734dde8f36ac1df8e275e768a7b333241cd637cb2538628a4b4", size = 453014, upload-time = "2026-10-09T20:30:26.192Z" }, - { url = "https://files.pythonhosted.org/packages/96/14/993a7ac9f3592f5628a12f6bc2495f5cce5814b883170e06f487dede6969/multidict-7.1.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:159976f9c40f96e3fe0952b708846a43a76bacb114e9cc828816f5080bddd5ec", size = 476343, upload-time = "2026-10-09T20:30:28.532Z" }, - { url = "https://files.pythonhosted.org/packages/b0/da/c2147c1e225a676ee0c97f97bb935fb8a70821bc12df240491bf9f140801/multidict-7.1.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:81a0e08c64dfdad27dab687b96f572b23bafa1999a39d1b6f70b3ddbb73e8bd0", size = 426813, upload-time = "2026-10-09T20:30:31.015Z" }, - { url = "https://files.pythonhosted.org/packages/5f/fa/8858b260a6662035670759e7b3a7da9decc227296491749073e76202de93/multidict-7.1.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:4e11e7299079718c78f8147e7206c22fe35bab4466d38992420795288a0b8096", size = 483882, upload-time = "2026-10-09T20:30:33.509Z" }, - { url = "https://files.pythonhosted.org/packages/c2/43/d42dc515d56d506f437e8a19c4f604719a306f8d2b60f70a14be34ac9231/multidict-7.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:34d2ee98e15d5cfe782a431bc913fce3b58cf3fdb34fcb437aeb275cdf9007ab", size = 469953, upload-time = "2026-10-09T20:30:35.996Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bd/1b6fb52f7e082b4d49b069d362169a2e6dbc43800f152f33c8f0e8933117/multidict-7.1.0-cp315-cp315-win32.whl", hash = "sha256:f376224572d1f5da1c871f969ab04765727f180e70d012d93e07bfc08442c64b", size = 80202, upload-time = "2026-10-09T20:30:38.121Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/5dec513dec950825529037703da89a597dc0b157e7b1b8d0304dd9e672cc/multidict-7.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:67fcf28db77b385820881521db7435e9f1c607cfaf07db6eb78aa9d1146bde86", size = 92921, upload-time = "2026-10-09T20:30:40.507Z" }, - { url = "https://files.pythonhosted.org/packages/dd/eb/fd62025f8cd3dad6f936df8bd7ff2642e2fe3fe0166ef2fc75699ee57cac/multidict-7.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:c7aafa4dd2f702ee2198005d6cba4309c1e25ed1c201d77beddefa47411bead8", size = 92848, upload-time = "2026-10-09T20:30:43.026Z" }, - { url = "https://files.pythonhosted.org/packages/42/f4/2dcd45731d2f57b87211a1dab1c29a72c6a4a527f370851c3e4cef5c4445/multidict-7.1.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1348ddc076251cd542f4a99ccda4b7c1f8444e8ab489d3541a978ca5901c7c1f", size = 177634, upload-time = "2026-10-09T20:30:45.125Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d6/d57b12a5bb19396a99ae66ec48ff69f764cc2c83e83bd9a003bd5ed5802a/multidict-7.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:9267bf8261a779abb2a6eab5f107f5db85b2d1745f2494081c731aaf28738ce3", size = 101954, upload-time = "2026-10-09T20:30:47.416Z" }, - { url = "https://files.pythonhosted.org/packages/46/8b/374e3f6e4581b8f8425712214cad854ff9370019483aaa49e6207c160adf/multidict-7.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:8a844b8b1685f38a2e8b2f3213b286e2a7abfe67508381780a0d4599ac337c1c", size = 105056, upload-time = "2026-10-09T20:30:49.618Z" }, - { url = "https://files.pythonhosted.org/packages/9e/0a/6c89e2179a84eaa78eec658b9a05f10d3e22654708d7b2e3ebdf0faa0614/multidict-7.1.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:18a447d46a3a2f1e61b365cbf5627db7030fdb707dad70c4f2760e5144166ecc", size = 467374, upload-time = "2026-10-09T20:30:51.933Z" }, - { url = "https://files.pythonhosted.org/packages/c5/62/431ed831b5e9b94c75ed0a410786d07176e879603601570eb25594039200/multidict-7.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88811f890db240a1c82bf0bcd52973763707a552c8113ac3fcebca183afb2fa8", size = 504024, upload-time = "2026-10-09T20:30:54.701Z" }, - { url = "https://files.pythonhosted.org/packages/d4/dc/52e4204538a7824e407e531b742ff6f7a2ff614dd40382d29a62755699cb/multidict-7.1.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a60b720c329c0007feae692b7bf91cf17b3f9bd3727be96cc6f9a3336651041b", size = 460139, upload-time = "2026-10-09T20:30:57.193Z" }, - { url = "https://files.pythonhosted.org/packages/22/4c/0228d61a8fee69a7ac4d8d0426aa1810b9c36b679a8c51844de701230073/multidict-7.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b117ed1cd1a23df0902461c38093408b95971833dcee629112acda25b603c8d0", size = 513162, upload-time = "2026-10-09T20:30:59.662Z" }, - { url = "https://files.pythonhosted.org/packages/56/50/a0b28bf4f036897bbd44c8761fbb384c014c34f621687799d07345b62820/multidict-7.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bbcae7a54050b7ad7bc7bf425ba63dea7d2cd31a92246ba787a2ce69a9b98dbc", size = 520312, upload-time = "2026-10-09T20:31:02.515Z" }, - { url = "https://files.pythonhosted.org/packages/4f/1d/ba96f77c24174eff9f6521cc7c83958f27598b924a9f7b6aca5d11c73f35/multidict-7.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cba2b0b9235fe10e12301d6b4cfba0f353fa668d635f6e988b03623c2cd42ba", size = 502953, upload-time = "2026-10-09T20:31:05.187Z" }, - { url = "https://files.pythonhosted.org/packages/59/f2/14c3ffb649cf45a629ec38ca757a493f0caf729e3f3580816dc7779e4caa/multidict-7.1.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:535173fbcc3933d84f9929d49d7a59a0faec259ee07d07c34c7d2a980b4e3683", size = 451497, upload-time = "2026-10-09T20:31:07.597Z" }, - { url = "https://files.pythonhosted.org/packages/55/df/fa4f8f6ee2314bdd3e4bc830c25f1d8a737188198abeaf105c73454f0b95/multidict-7.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ea027bdeca1d7e498237634ee4e3a852e2723eef39996dec0ff0f77dff8a2336", size = 486212, upload-time = "2026-10-09T20:31:10.178Z" }, - { url = "https://files.pythonhosted.org/packages/32/5e/3227762b04a8ff1a90359ee2c04af9ed095fe3892e101705fa4233b51514/multidict-7.1.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:507151e1e3dee95e9e8159e329aed4f75aa5205ecd6505a4f6be546890eafbe1", size = 459864, upload-time = "2026-10-09T20:31:13.044Z" }, - { url = "https://files.pythonhosted.org/packages/5e/04/58524c7e82176438a48704a687fb383cc943dd7075c67b5bea288043b504/multidict-7.1.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9c10791e9f5ef132effc8fdce2009482c1cfb26618c5fc1b7952a47dd5eb632e", size = 465861, upload-time = "2026-10-09T20:31:15.503Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d6/28e549b6ec0c829647de4c75cf32e4c7ea0d0e87b91dfb399292f630c365/multidict-7.1.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f16ac8af2804855d3cae5fc3c5ab609c9fd0fc8ecacd92579c05ed3c173396fd", size = 498865, upload-time = "2026-10-09T20:31:18.548Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ac/3006640586b4d33442c53989b78fc290c8eae5fab5b62a31ac7a4c220e69/multidict-7.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b78de22bae456a976f33df34d598dfd16edc9a03df8f4cc8b7c17bdba4c97b4a", size = 449885, upload-time = "2026-10-09T20:31:21.03Z" }, - { url = "https://files.pythonhosted.org/packages/80/7e/1ece407cee9f9aff9ace9e44379ed937bccb790936ae448feb79a82c362e/multidict-7.1.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:db77888081431aaa69f3fd3480891746ddce6c2a571f6201869a24e2f06cf423", size = 500237, upload-time = "2026-10-09T20:31:23.611Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4c/c052b700ffcf689454848ee81ba87bedfdc10caff592c0d2a327c43939a5/multidict-7.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c39dfcaa0bf23443474c0cb58d8d8aea9529c1841d99654cb38e4dada7b1948a", size = 495143, upload-time = "2026-10-09T20:31:26.237Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ef/9ddad94c32fb0bdfc5ad0942d06ae6699bd30d261b0ff59b94810a133238/multidict-7.1.0-cp315-cp315t-win32.whl", hash = "sha256:16b21164797bde6f417066d02775975cc2e15ab8abf80efa55fe85e0b4894020", size = 86526, upload-time = "2026-10-09T20:31:28.734Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ce/2efa1f32a07adfe041d48c6d04c08b6f9db66b8512c765983172399b2855/multidict-7.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:55392202cb374dd1a1f89a8ce1586644870d9e936752059d053e576acc50bc89", size = 99671, upload-time = "2026-10-09T20:31:31.282Z" }, - { url = "https://files.pythonhosted.org/packages/a9/b3/a44c301fe13716c5f2c08afd89a481018988cde4b337b36e28a2b0fcf3a3/multidict-7.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f979a077d1c0a9a36dd4fab0d3a36b8de7b593bf935e13df85a380395b2c11ad", size = 98893, upload-time = "2026-10-09T20:31:33.62Z" }, - { url = "https://files.pythonhosted.org/packages/d0/86/a3de309c5e28ee85b314d0e3ba0e0dea6fd361c313322a05e67be4656e1e/multidict-7.1.0-py3-none-any.whl", hash = "sha256:d9ef29cfd98e17085b4f91bba8fa1570bec6787d5c52ce653ed33a58785585d0", size = 28087, upload-time = "2026-10-09T20:31:35.945Z" }, +version = "6.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, ] [[package]] @@ -2178,6 +2230,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/20/b685ed7af2e17c29ffc8af56f1fa8bc2033258fc30fb0d2b722f49d13ba0/opentelemetry_exporter_otlp_proto_http-1.43.0-py3-none-any.whl", hash = "sha256:647f603aa8efdbdb4dbff842e0729d0406a6fff26b295a72d3d60e7d963b2610", size = 21795, upload-time = "2026-06-24T15:19:43.164Z" }, ] +[[package]] +name = "opentelemetry-instrumentation" +version = "0.64b0" +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/7e/97/02fe6e1c8b1ffac42d0b429c18080edb24e0e0d18c86612edf72b5752382/opentelemetry_instrumentation-0.64b0.tar.gz", hash = "sha256:b47d528dead6271d7743114417eb67fc915bd9258111c48dbf9a4951d2efa88d", size = 41935, upload-time = "2026-06-24T15:19:12.951Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/0c/cb9fe342de5299c7af24582eb7d788661cc53a1c4b904da92309caaa9417/opentelemetry_instrumentation-0.64b0-py3-none-any.whl", hash = "sha256:133ab7ffca796557aec059bf6be3190a34b6dea987f25be3d9409e230cbdad8b", size = 35880, upload-time = "2026-06-24T15:18:17.277Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-threading" +version = "0.64b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/ef/d4a16eb4a0f0971e351ec2ff95846b501b66a619d07d90822942d70a3adc/opentelemetry_instrumentation_threading-0.64b0.tar.gz", hash = "sha256:0a07d7329f69dfae5036a7cb184f502c8b91cb0538012f5304bd32ffe9ade451", size = 9080, upload-time = "2026-06-24T15:19:43.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b3/8838d5f3db35b3854516ccfbf8ec26138d40388d828407182c890c3aa9d0/opentelemetry_instrumentation_threading-0.64b0-py3-none-any.whl", hash = "sha256:a285ffa750a958f7d368e947f5679a0214d588242cbffba0f5934cb02e9a17f4", size = 8487, upload-time = "2026-06-24T15:19:00.813Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.43.0" @@ -3047,14 +3128,14 @@ wheels = [ [[package]] name = "s3transfer" -version = "0.19.2" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, ] [[package]] @@ -3110,6 +3191,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "strands-agents" +version = "1.62.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jsonschema" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation-threading" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "typing-extensions" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/4a/b129a14394b35a24ebecea48a68523ee41adc874d4ced07d47e4ccee580a/strands_agents-1.62.0.tar.gz", hash = "sha256:8732c87f9c1ceb6ef4c5c4bd6d96b4cdf1bbfde095f1537ec2ac6fddb8d5a066", size = 1831346, upload-time = "2026-10-21T18:47:51.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/ea/47b842643804b59e32fac5116ed6aa8a07b3c1a089728f9e2be0a076fa02/strands_agents-1.62.0-py3-none-any.whl", hash = "sha256:331c94c61a5ddf2c4f06dfd877551bdd1d0bfee5ea89b76d6c546b65ea2104e1", size = 923503, upload-time = "2026-10-21T18:47:49.513Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -3344,6 +3449,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "websockets" version = "15.0.1" @@ -3375,6 +3504,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[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/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { 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 = "xxhash" version = "3.8.0"