mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/mock-testing-feature-flag-6e30ad
This commit is contained in:
commit
2a9aa966ce
30 changed files with 2007 additions and 494 deletions
|
|
@ -4436,13 +4436,18 @@ class MCPServerManager:
|
|||
arguments: dict[str, Any],
|
||||
server_name: str,
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth],
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
proxy_logging_obj: ProxyLogging | None,
|
||||
server: MCPServer,
|
||||
raw_headers: Optional[dict[str, str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run pre-call checks and guardrail hooks for an MCP tool call.
|
||||
|
||||
Authorization runs unconditionally; only the guardrail hooks, which are
|
||||
dispatched through ``proxy_logging_obj``, depend on a logger being
|
||||
present. An absent logger must never be able to turn an authorization
|
||||
decision into a no-op.
|
||||
|
||||
Returns a dict that may contain:
|
||||
- "arguments": hook-modified tool arguments (only if changed)
|
||||
- "extra_headers": headers injected by pre_mcp_call guardrail hooks
|
||||
|
|
@ -4470,6 +4475,10 @@ class MCPServerManager:
|
|||
server=server,
|
||||
)
|
||||
|
||||
hook_result: dict[str, Any] = {}
|
||||
if proxy_logging_obj is None:
|
||||
return hook_result
|
||||
|
||||
# Extract incoming Bearer token from raw request headers so
|
||||
# guardrails like MCPJWTSigner can verify + re-sign it (FR-5).
|
||||
normalized_raw = {k.lower(): v for k, v in (raw_headers or {}).items()}
|
||||
|
|
@ -4499,7 +4508,6 @@ class MCPServerManager:
|
|||
# Convert to LLM format for existing guardrail compatibility
|
||||
synthetic_llm_data = proxy_logging_obj._convert_mcp_to_llm_format(mcp_request_obj, pre_hook_kwargs)
|
||||
|
||||
hook_result: dict[str, Any] = {}
|
||||
try:
|
||||
# Use standard pre_call_hook
|
||||
modified_data = await proxy_logging_obj.pre_call_hook(
|
||||
|
|
@ -5125,19 +5133,17 @@ class MCPServerManager:
|
|||
# Allow validation and modification of tool calls before execution
|
||||
# Using standard pre_call_hook
|
||||
#########################################################
|
||||
hook_result: dict[str, Any] = {}
|
||||
if proxy_logging_obj:
|
||||
hook_result = await self.pre_call_tool_check(
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
server_name=server_name,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
server=mcp_server,
|
||||
raw_headers=raw_headers,
|
||||
)
|
||||
if "arguments" in hook_result:
|
||||
arguments = hook_result["arguments"]
|
||||
hook_result: dict[str, Any] = await self.pre_call_tool_check(
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
server_name=server_name,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
server=mcp_server,
|
||||
raw_headers=raw_headers,
|
||||
)
|
||||
if "arguments" in hook_result:
|
||||
arguments = hook_result["arguments"]
|
||||
|
||||
# Prepare tasks for during hooks
|
||||
tasks = []
|
||||
|
|
|
|||
|
|
@ -2902,6 +2902,54 @@ if MCP_AVAILABLE:
|
|||
# Deprecated: Local MCP Server Tool
|
||||
#########################################################
|
||||
else:
|
||||
# Gate only what can actually dispatch. When the unprefixed name is
|
||||
# not in the registry either, `_handle_local_mcp_tool` below reports
|
||||
# 404 and nothing runs, so demanding a server here would turn every
|
||||
# unknown tool name into a misleading 503.
|
||||
if global_mcp_tool_registry.get_tool(original_tool_name) is not None:
|
||||
# `mcp_server` is None here because the tool name is not in the
|
||||
# tool -> server mapping, but the name still carries a prefix
|
||||
# that the server-level check above compared against the
|
||||
# caller's `allowed_mcp_servers` by exact `name`. So the named
|
||||
# server is in that list and can carry the tool-level checks,
|
||||
# even with the mapping cold. Resolve it from
|
||||
# `allowed_mcp_servers` rather than the registry: the registry
|
||||
# would happily return a server the caller holds no grant for,
|
||||
# and matching anything other than `name` would accept a server
|
||||
# the check never validated.
|
||||
prefix_server = next(
|
||||
(candidate for candidate in allowed_mcp_servers if candidate.name == server_name),
|
||||
None,
|
||||
)
|
||||
if prefix_server is None:
|
||||
# A non-empty prefix that passed the server-level check
|
||||
# always matches here, so this arm only fires when the
|
||||
# prefix was empty, which is exactly the case that check
|
||||
# skips. Fail closed rather than dispatch with no server to
|
||||
# evaluate a tool ceiling against.
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
f"MCP server for tool '{original_tool_name}' is not available; "
|
||||
"refusing to dispatch without authorization checks. "
|
||||
"Retry once the server is registered."
|
||||
),
|
||||
)
|
||||
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj
|
||||
|
||||
hook_result = await global_mcp_server_manager.pre_call_tool_check(
|
||||
name=original_tool_name,
|
||||
arguments=arguments,
|
||||
server_name=server_name,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
server=prefix_server,
|
||||
raw_headers=raw_headers,
|
||||
)
|
||||
if "arguments" in hook_result:
|
||||
arguments = hook_result["arguments"] # pyright: ignore[reportAny] # hook returns untyped args
|
||||
|
||||
local_content = await _handle_local_mcp_tool(original_tool_name, arguments)
|
||||
response = CallToolResult(content=cast(Any, local_content), isError=False)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ dependencies = [
|
|||
"tokenizers>=0.21.0,<1.0",
|
||||
"click>=8.0.0,<9.0",
|
||||
"jinja2>=3.1.6,<4.0",
|
||||
"aiohttp>=3.10,<4.0",
|
||||
"aiohttp>=3.14.2,<4.0",
|
||||
"pydantic>=2.10.0,<3.0.0",
|
||||
"jsonschema>=4.0.0,<5.0",
|
||||
]
|
||||
|
|
@ -277,7 +277,7 @@ exclude = [
|
|||
[tool.uv]
|
||||
constraint-dependencies = [
|
||||
"tornado>=6.5.6",
|
||||
"aiohttp>=3.14.1,<4.0",
|
||||
"aiohttp>=3.14.2,<4.0",
|
||||
"packaging>=24.0",
|
||||
"soupsieve>=2.8.4",
|
||||
"httplib2>=0.32.0",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ LIT002 Mutable-collection *construction*: a list/dict/set literal or comprehens
|
|||
generator (`tuple(f(x) for x in xs)`), a tuple literal, or a frozen dataclass /
|
||||
NamedTuple / ReadOnly TypedDict. Generator expressions and `tuple`/`frozenset`
|
||||
calls are not construction and pass. Annotation-internal lists (`Callable[[int],
|
||||
str]`) are exempt. Suppress with `# mutable-ok: <reason>`.
|
||||
str]`) are exempt, as is a value passed directly to a freezing wrapper
|
||||
(`tuple(...)`, `frozenset(...)`, `MappingProxyType(...)`): it is frozen before
|
||||
it can escape, though anything mutable nested inside it still counts.
|
||||
Suppress with `# mutable-ok: <reason>`.
|
||||
LIT003 noqa suppression without rule codes or without a reason.
|
||||
Required shape: `# noqa: TID251 # <reason>`
|
||||
LIT004 pyright/mypy ignore without bracketed codes or without a reason.
|
||||
|
|
@ -90,6 +93,7 @@ MUTABLE_CONSTRUCTORS = frozenset((
|
|||
# are common methods (e.g. pydantic's `model.dict()`), not collection construction. A
|
||||
# qualified `collections.deque(...)` still counts.
|
||||
QUALIFIED_CONSTRUCTORS = MUTABLE_CONSTRUCTORS - frozenset(("dict", "list", "set"))
|
||||
FREEZING_WRAPPERS = frozenset(("tuple", "frozenset", "MappingProxyType"))
|
||||
UNSAFE_GUARDS = frozenset(("TypeGuard", "TypeIs"))
|
||||
MIN_REASON_LEN = 3
|
||||
|
||||
|
|
@ -382,6 +386,34 @@ def _annotation_node_ids(tree: ast.AST) -> frozenset[int]:
|
|||
)
|
||||
|
||||
|
||||
def _is_freezing_wrapper(func: ast.expr) -> bool:
|
||||
if isinstance(func, ast.Name):
|
||||
return func.id in FREEZING_WRAPPERS
|
||||
return (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr == "MappingProxyType"
|
||||
and isinstance(func.value, ast.Name)
|
||||
and func.value.id == "types"
|
||||
)
|
||||
|
||||
|
||||
def _frozen_argument_ids(tree: ast.AST) -> frozenset[int]:
|
||||
"""ids() of every expression passed directly to a freezing wrapper.
|
||||
|
||||
`MappingProxyType({...})`, `frozenset({...})`, and `tuple([...])` freeze their
|
||||
argument before it can escape, so the literal inside is a one-shot build, not a
|
||||
mutable value anyone can grow later. Only the argument itself is exempt; a
|
||||
mutable collection nested inside it still trips LIT002. Only bare names (plus
|
||||
`types.MappingProxyType`) qualify, so an unrelated method that happens to share
|
||||
a wrapper's name cannot exempt its argument.
|
||||
"""
|
||||
return frozenset(
|
||||
id(node.args[0])
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call) and len(node.args) == 1 and _is_freezing_wrapper(node.func)
|
||||
)
|
||||
|
||||
|
||||
def _construction_kind(node: ast.expr) -> str | None:
|
||||
"""Human label if `node` builds a mutable collection, else None."""
|
||||
if isinstance(node, ast.List):
|
||||
|
|
@ -407,8 +439,9 @@ def _construction_kind(node: ast.expr) -> str | None:
|
|||
|
||||
def iter_construction_violations(path: Path, tree: ast.AST, comments: Comments) -> Iterator[Violation]:
|
||||
in_annotation = _annotation_node_ids(tree)
|
||||
frozen_arguments = _frozen_argument_ids(tree)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.expr) or id(node) in in_annotation:
|
||||
if not isinstance(node, ast.expr) or id(node) in in_annotation or id(node) in frozen_arguments:
|
||||
continue
|
||||
kind = _construction_kind(node)
|
||||
if kind is None or node.lineno in comments.mutable_ok_lines:
|
||||
|
|
|
|||
|
|
@ -142,6 +142,77 @@ def test_cli_extra_is_a_thin_client_install():
|
|||
assert not leaked, f"`cli` extra leaks proxy-server deps onto laptops: {leaked}"
|
||||
|
||||
|
||||
AIOHTTP_POOL_POISONING_RANGE = ">=3.14.0,<3.14.2"
|
||||
AIOHTTP_POOL_POISONING_RELEASES = ("3.14.0", "3.14.1")
|
||||
|
||||
|
||||
def _load_toml(path):
|
||||
try:
|
||||
import tomllib as tomli
|
||||
except ImportError:
|
||||
try:
|
||||
import tomli
|
||||
except ImportError:
|
||||
pytest.skip("tomli/tomllib not available - skipping dependency check")
|
||||
|
||||
with open(path, "rb") as f:
|
||||
return tomli.load(f)
|
||||
|
||||
|
||||
def _declared_aiohttp_specifier():
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
pyproject = _load_toml(os.path.join(PROJECT_ROOT, "pyproject.toml"))
|
||||
for requirement in pyproject["project"]["dependencies"]:
|
||||
parsed = Requirement(requirement)
|
||||
if parsed.name.lower() == "aiohttp":
|
||||
return parsed.specifier
|
||||
pytest.fail("aiohttp is no longer a declared runtime dependency of litellm")
|
||||
|
||||
|
||||
def _locked_aiohttp_version():
|
||||
lock = _load_toml(os.path.join(PROJECT_ROOT, "uv.lock"))
|
||||
for package in lock["package"]:
|
||||
if package["name"].lower() == "aiohttp":
|
||||
return package["version"]
|
||||
pytest.fail("aiohttp is missing from uv.lock")
|
||||
|
||||
|
||||
def test_declared_aiohttp_floor_excludes_pool_poisoning_releases():
|
||||
"""aiohttp 3.14.0/3.14.1 re-arm the sock_read timer on a keep-alive connection
|
||||
after it is back in the idle pool, so the next request to reuse it fails
|
||||
instantly with a bogus timeout (aio-libs/aiohttp#12953, fixed in 3.14.2).
|
||||
|
||||
The wheel's own metadata is what pip resolves against, so the floor declared
|
||||
here - not just the lockfile - has to exclude that range.
|
||||
"""
|
||||
specifier = _declared_aiohttp_specifier()
|
||||
|
||||
admitted = [v for v in AIOHTTP_POOL_POISONING_RELEASES if specifier.contains(v)]
|
||||
assert not admitted, (
|
||||
f"litellm declares aiohttp{specifier}, which still admits {admitted}. "
|
||||
"Those releases poison pooled keep-alive connections and cause "
|
||||
"cross-provider sub-millisecond 'Connection timed out' failures; "
|
||||
"keep the floor at >=3.14.2."
|
||||
)
|
||||
|
||||
|
||||
def test_locked_aiohttp_version_is_not_pool_poisoning():
|
||||
"""uv.lock is what the published Docker images install (uv sync --frozen), so a
|
||||
lock that drifts back onto 3.14.0/3.14.1 ships the regression regardless of
|
||||
what pyproject.toml declares.
|
||||
"""
|
||||
from packaging.specifiers import SpecifierSet
|
||||
|
||||
locked = _locked_aiohttp_version()
|
||||
|
||||
assert not SpecifierSet(AIOHTTP_POOL_POISONING_RANGE).contains(locked), (
|
||||
f"uv.lock resolves aiohttp {locked}, which is inside the pool-poisoning "
|
||||
f"range {AIOHTTP_POOL_POISONING_RANGE} (aio-libs/aiohttp#12953). "
|
||||
"Re-run `uv lock` against an aiohttp>=3.14.2 floor."
|
||||
)
|
||||
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
|
|
|||
|
|
@ -46,10 +46,13 @@ from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
|||
)
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_MCPServerTable,
|
||||
LiteLLM_ObjectPermissionTable,
|
||||
LitellmUserRoles,
|
||||
MCPApprovalStatus,
|
||||
MCPEnvVar,
|
||||
MCPEnvVarScope,
|
||||
MCPTransport,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth, MCPAuthType
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthMetadata, MCPServer
|
||||
|
|
@ -9718,3 +9721,75 @@ class TestOpenAPIRegistryKeyMatchesRegistration:
|
|||
|
||||
assert result.isError is True
|
||||
assert "not found in registry" in result.content[0].text
|
||||
|
||||
|
||||
class TestToolAuthorizationIsNotConditionalOnLogging:
|
||||
"""`call_tool` used to run `pre_call_tool_check` — the only place tool-level
|
||||
MCP entitlements are enforced — inside `if proxy_logging_obj:`, so a caller
|
||||
reached with no logging object got no authorization decision at all. Both
|
||||
production call sites pass the module-level `ProxyLogging` singleton, which
|
||||
is never None, so this was not a live hole; the invariant being restored is
|
||||
that an authorization decision cannot be skipped by an absent logger.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _manager_with_scoped_server() -> tuple[MCPServerManager, UserAPIKeyAuth]:
|
||||
manager = MCPServerManager()
|
||||
manager.registry["srv-gated"] = MCPServer(
|
||||
server_id="srv-gated",
|
||||
name="gated_server",
|
||||
server_name="gated_server",
|
||||
alias="gated_server",
|
||||
url="http://127.0.0.1:1/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
)
|
||||
for tool_name in ("read_only_tool", "delete_everything"):
|
||||
manager.tool_name_to_mcp_server_name_mapping[tool_name] = "gated_server"
|
||||
user = UserAPIKeyAuth(
|
||||
api_key="sk-caller",
|
||||
user_id="alice",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-gated",
|
||||
mcp_servers=["srv-gated"],
|
||||
mcp_tool_permissions={"srv-gated": ["read_only_tool"]},
|
||||
),
|
||||
)
|
||||
return manager, user
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unentitled_tool_refused_without_proxy_logging_obj(self):
|
||||
manager, user = self._manager_with_scoped_server()
|
||||
upstream = AsyncMock(return_value=CallToolResult(content=[], isError=False))
|
||||
|
||||
with patch.object(manager, "_call_regular_mcp_tool", new=upstream):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await manager.call_tool(
|
||||
server_name="gated_server",
|
||||
name="delete_everything",
|
||||
arguments={},
|
||||
user_api_key_auth=user,
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
upstream.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entitled_tool_still_dispatches_without_proxy_logging_obj(self):
|
||||
"""The gate must refuse only what the entitlement excludes; an allowed
|
||||
tool still reaches the upstream when there is no logging object."""
|
||||
manager, user = self._manager_with_scoped_server()
|
||||
upstream = AsyncMock(return_value=CallToolResult(content=[], isError=False))
|
||||
|
||||
with patch.object(manager, "_call_regular_mcp_tool", new=upstream):
|
||||
await manager.call_tool(
|
||||
server_name="gated_server",
|
||||
name="read_only_tool",
|
||||
arguments={},
|
||||
user_api_key_auth=user,
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
|
||||
upstream.assert_awaited_once()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_ObjectPermissionTable,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth, MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -305,3 +311,241 @@ async def test_openapi_local_tool_injects_resolved_oauth_token():
|
|||
|
||||
assert captured["resolved"] == {"Authorization": "Bearer stored-user-token"}
|
||||
assert _request_resolved_auth_headers.get() is None
|
||||
|
||||
|
||||
|
||||
LEGACY_SERVER_ID = "srv-legacy-petstore"
|
||||
LEGACY_SERVER_NAME = "legacy_petstore"
|
||||
LEGACY_TOOL = "dump_secrets"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legacy_local_tool():
|
||||
"""A bare `mcp_tools`-style handler plus a registered server whose tools were
|
||||
never listed, which is what leaves `tool_name_to_mcp_server_name_mapping`
|
||||
cold and routes `{server}-{tool}` into `execute_mcp_tool`'s legacy fallback.
|
||||
|
||||
Yields the server and the list the handler appends to, so a test can tell
|
||||
"refused" from "dispatched" by whether the handler actually ran.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.tool_registry import (
|
||||
global_mcp_tool_registry,
|
||||
)
|
||||
|
||||
executed: list[dict] = []
|
||||
server = MCPServer(
|
||||
server_id=LEGACY_SERVER_ID,
|
||||
name=LEGACY_SERVER_NAME,
|
||||
server_name=LEGACY_SERVER_NAME,
|
||||
alias=LEGACY_SERVER_NAME,
|
||||
url="http://127.0.0.1:1/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
)
|
||||
global_mcp_tool_registry.register_tool(
|
||||
name=LEGACY_TOOL,
|
||||
description="bare tool registered from the mcp_tools config block",
|
||||
input_schema={"type": "object", "properties": {}},
|
||||
handler=lambda **kwargs: executed.append(kwargs) or "legacy local tool ran",
|
||||
)
|
||||
global_mcp_server_manager.registry[LEGACY_SERVER_ID] = server
|
||||
assert (
|
||||
global_mcp_server_manager._get_mcp_server_from_tool_name(
|
||||
f"{LEGACY_SERVER_NAME}-{LEGACY_TOOL}"
|
||||
)
|
||||
is None
|
||||
), "fixture precondition: the prefixed name must resolve to no server"
|
||||
try:
|
||||
yield server, executed
|
||||
finally:
|
||||
global_mcp_tool_registry.tools.pop(LEGACY_TOOL, None)
|
||||
global_mcp_server_manager.registry.pop(LEGACY_SERVER_ID, None)
|
||||
|
||||
|
||||
def _caller_entitled_to(tools: list[str]) -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(
|
||||
api_key="sk-caller",
|
||||
user_id="alice",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-legacy-fallback",
|
||||
mcp_servers=[LEGACY_SERVER_ID],
|
||||
mcp_tool_permissions={LEGACY_SERVER_ID: tools},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_local_tool_fallback_refuses_unentitled_caller(legacy_local_tool):
|
||||
"""The legacy fallback dispatched into the local tool registry with no
|
||||
tool-level authorization at all: no allowed/banned check, no key/team/org
|
||||
tool permissions, no parameter validation. It must now run the same gate,
|
||||
so a caller whose entitlement excludes the tool is refused and the handler
|
||||
never runs.
|
||||
|
||||
Nothing is mocked: the real registries and the real entitlement gate decide.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
||||
MCPRequestHandler,
|
||||
)
|
||||
|
||||
server, executed = legacy_local_tool
|
||||
user = _caller_entitled_to(["list_pets"])
|
||||
|
||||
# The gate answers "no" for this caller/tool pair, so a dispatch below would
|
||||
# be an entitlement bypass rather than a routing quirk.
|
||||
assert (
|
||||
await MCPRequestHandler.is_tool_allowed_for_server(
|
||||
tool_name=LEGACY_TOOL,
|
||||
server_id=LEGACY_SERVER_ID,
|
||||
user_api_key_auth=user,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await mcp_module.execute_mcp_tool(
|
||||
name=f"{LEGACY_SERVER_NAME}-{LEGACY_TOOL}",
|
||||
arguments={},
|
||||
allowed_mcp_servers=[server],
|
||||
start_time=datetime.now(timezone.utc),
|
||||
user_api_key_auth=user,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
# Pin the refusal to the ENTITLEMENT gate. The server-level check earlier in
|
||||
# execute_mcp_tool also raises 403 (with a plain-string detail), and the
|
||||
# allowed/banned-tools check raises a dict naming the server rather than the
|
||||
# key/team, so asserting on the status alone would pass for the wrong reason.
|
||||
detail = exc.value.detail
|
||||
assert isinstance(detail, dict), detail
|
||||
assert "not allowed for your key/team" in detail["error"], detail
|
||||
assert executed == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_local_tool_fallback_still_dispatches_entitled_caller(
|
||||
legacy_local_tool,
|
||||
):
|
||||
"""The gate must do per-tool work rather than disabling the fallback: the
|
||||
same shape of call, from a caller entitled to the tool, still dispatches.
|
||||
|
||||
This is the backwards-compatibility half. Refusing this call would trade an
|
||||
authorization hole for an outage on a configuration that worked before.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
server, executed = legacy_local_tool
|
||||
user = _caller_entitled_to([LEGACY_TOOL])
|
||||
|
||||
result = await mcp_module.execute_mcp_tool(
|
||||
name=f"{LEGACY_SERVER_NAME}-{LEGACY_TOOL}",
|
||||
arguments={},
|
||||
allowed_mcp_servers=[server],
|
||||
start_time=datetime.now(timezone.utc),
|
||||
user_api_key_auth=user,
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
assert executed == [{}]
|
||||
assert "legacy local tool ran" in result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_local_tool_fallback_fails_closed_on_empty_prefix(
|
||||
legacy_local_tool,
|
||||
):
|
||||
"""An empty prefix segment skips the server-level check outright:
|
||||
`split_server_prefix_from_name` yields an empty `server_name`, and `execute_mcp_tool`
|
||||
only runs `is_tool_allowed` `if server_name`. The legacy fallback then dispatched for a
|
||||
caller holding no server grant at all, so this arm of the guard is reachable rather than
|
||||
defensive. Nothing is patched here; the empty prefix segment is the whole of it.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
_server, executed = legacy_local_tool
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await mcp_module.execute_mcp_tool(
|
||||
name=f"-{LEGACY_TOOL}",
|
||||
arguments={},
|
||||
allowed_mcp_servers=[],
|
||||
start_time=datetime.now(timezone.utc),
|
||||
user_api_key_auth=_caller_entitled_to([LEGACY_TOOL]),
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
assert executed == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_local_tool_fallback_fails_closed_when_prefix_names_no_server(
|
||||
legacy_local_tool,
|
||||
):
|
||||
"""Second arm of the same guard: a non-empty prefix that named a server the caller does
|
||||
hold, but which is absent from `allowed_mcp_servers` by the time dispatch runs. Patching
|
||||
the server-level check (which would otherwise refuse first) is what makes the arm
|
||||
observable, so a later refactor cannot make the branch dispatch with no server to
|
||||
evaluate a tool ceiling against.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
_server, executed = legacy_local_tool
|
||||
other_server = MCPServer(
|
||||
server_id="srv-unrelated",
|
||||
name="unrelated_server",
|
||||
server_name="unrelated_server",
|
||||
alias="unrelated_server",
|
||||
url="http://127.0.0.1:1/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.server.MCPRequestHandler.is_tool_allowed",
|
||||
return_value=True,
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await mcp_module.execute_mcp_tool(
|
||||
name=f"{LEGACY_SERVER_NAME}-{LEGACY_TOOL}",
|
||||
arguments={},
|
||||
allowed_mcp_servers=[other_server],
|
||||
start_time=datetime.now(timezone.utc),
|
||||
user_api_key_auth=_caller_entitled_to([LEGACY_TOOL]),
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
assert executed == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_tool_name_still_reports_not_found():
|
||||
"""The guard must gate dispatch, not existence. An unprefixed name that no registry
|
||||
knows cannot dispatch anything, so it has to keep reporting 404 rather than collapsing
|
||||
into the guard's 503; every typo'd tool name takes this branch.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await mcp_module.execute_mcp_tool(
|
||||
name="tool_no_registry_knows",
|
||||
arguments={},
|
||||
allowed_mcp_servers=[],
|
||||
start_time=datetime.now(timezone.utc),
|
||||
user_api_key_auth=_caller_entitled_to([LEGACY_TOOL]),
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
assert "not found" in str(exc.value.detail)
|
||||
|
|
|
|||
|
|
@ -152,6 +152,28 @@ def test_qualified_collections_constructors_still_count(tmp_path):
|
|||
assert "LIT002" in _codes(tmp_path, "import collections\nm = collections.defaultdict(list)\n")
|
||||
|
||||
|
||||
def test_value_frozen_by_wrapper_is_exempt(tmp_path):
|
||||
assert "LIT002" not in _codes(tmp_path, "from types import MappingProxyType\nm = MappingProxyType({'a': 1})\n")
|
||||
assert "LIT002" not in _codes(tmp_path, "import types\nm = types.MappingProxyType({'a': 1})\n")
|
||||
assert "LIT002" not in _codes(tmp_path, "from types import MappingProxyType\nm = MappingProxyType(dict(a=1))\n")
|
||||
assert "LIT002" not in _codes(tmp_path, "f = frozenset({1, 2})\n")
|
||||
assert "LIT002" not in _codes(tmp_path, "t = tuple([1, 2])\n")
|
||||
|
||||
|
||||
def test_same_named_method_does_not_exempt_its_argument(tmp_path):
|
||||
assert "LIT002" in _codes(tmp_path, "t = obj.tuple([1, 2])\n")
|
||||
assert "LIT002" in _codes(tmp_path, "f = obj.frozenset({1, 2})\n")
|
||||
assert "LIT002" in _codes(tmp_path, "m = obj.MappingProxyType({'a': 1})\n")
|
||||
|
||||
|
||||
def test_mutable_nested_inside_frozen_wrapper_still_counts(tmp_path):
|
||||
assert "LIT002" in _codes(tmp_path, "from types import MappingProxyType\nm = MappingProxyType({'a': []})\n")
|
||||
|
||||
|
||||
def test_unfrozen_literal_still_counts(tmp_path):
|
||||
assert "LIT002" in _codes(tmp_path, "from types import MappingProxyType\nd = {'a': 1}\nm = MappingProxyType(d)\n")
|
||||
|
||||
|
||||
def test_mutable_ok_with_reason_suppresses_both_rules(tmp_path):
|
||||
codes = _codes(tmp_path, "x: dict[str, int] = {} # mutable-ok: in-place buffer mutated hot path\n")
|
||||
assert "LIT001" not in codes
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"limit": 23253
|
||||
},
|
||||
"LIT002": {
|
||||
"limit": 27427
|
||||
"limit": 27280
|
||||
},
|
||||
"LIT003": {
|
||||
"limit": 292
|
||||
|
|
|
|||
|
|
@ -126,11 +126,6 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/budgets/_components/budget_panel.test.tsx": {
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/budgets/_components/budget_panel.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,22 +1,63 @@
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import BudgetTable from "./BudgetTable";
|
||||
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
import type { ResourceListResult } from "@/app/(dashboard)/hooks/common/useResourceList";
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
||||
const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/utils/dataUtils", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/utils/dataUtils")>()),
|
||||
copyToClipboard: copyToClipboardMock,
|
||||
}));
|
||||
|
||||
const makeBudget = (overrides: Partial<budgetItem> = {}): budgetItem => ({
|
||||
budget_id: "budget-1",
|
||||
max_budget: 100,
|
||||
soft_budget: null,
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 10,
|
||||
budget_duration: "30d",
|
||||
budget_reset_at: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
budgets: [makeBudget()],
|
||||
const makeList = (overrides: Partial<ResourceListResult<budgetItem>> = {}): ResourceListResult<budgetItem> => ({
|
||||
rows: [makeBudget()],
|
||||
rowCount: 1,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
sorting: [{ id: "created_at", desc: true }],
|
||||
onSortingChange: vi.fn(),
|
||||
pagination: { pageIndex: 0, pageSize: 50 },
|
||||
onPaginationChange: vi.fn(),
|
||||
columnFilters: [],
|
||||
onColumnFiltersChange: vi.fn(),
|
||||
searchValue: "",
|
||||
onSearchChange: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const FORBIDDEN_PROBLEM = {
|
||||
type: "about:blank",
|
||||
title: "Forbidden",
|
||||
status: 403,
|
||||
detail: "Only proxy admins can view budgets",
|
||||
};
|
||||
|
||||
const showColumn = async (user: ReturnType<typeof userEvent.setup>, columnId: string) => {
|
||||
await user.click(screen.getByTestId("view-options-trigger"));
|
||||
await user.click(await screen.findByTestId(`view-option-${columnId}`));
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
canModify: true,
|
||||
onEditClick: vi.fn(),
|
||||
onDeleteClick: vi.fn(),
|
||||
|
|
@ -25,72 +66,151 @@ const defaultProps = {
|
|||
describe("BudgetTable", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testQueryClient.clear();
|
||||
});
|
||||
|
||||
it("should display budget information", () => {
|
||||
renderWithProviders(<BudgetTable {...defaultProps} />);
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={makeList()} />);
|
||||
expect(screen.getByText("budget-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("$100.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("1000")).toBeInTheDocument();
|
||||
expect(screen.getByText("10")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the budget id without a fixed character-count clamp", () => {
|
||||
it("should open on the four columns the page has always shown, with reset and created off", () => {
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={makeList()} />);
|
||||
const headers = screen.getAllByRole("columnheader").map((header) => header.textContent);
|
||||
expect(headers).toEqual(expect.arrayContaining(["Budget ID", "Max Budget", "TPM", "RPM"]));
|
||||
expect(headers).not.toContain("Reset");
|
||||
expect(headers).not.toContain("Created");
|
||||
});
|
||||
|
||||
it("should render the reset column with the friendly duration label once it is turned on", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={makeList()} />);
|
||||
await showColumn(user, "budget_duration");
|
||||
expect(screen.getByText("monthly")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render 'Not set' when a budget has no reset duration", async () => {
|
||||
const user = userEvent.setup();
|
||||
const list = makeList({ rows: [makeBudget({ budget_duration: null })] });
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
|
||||
await showColumn(user, "budget_duration");
|
||||
expect(screen.getByText("Not set")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the budget id in full, with no truncation", () => {
|
||||
const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d";
|
||||
renderWithProviders(<BudgetTable {...defaultProps} budgets={[makeBudget({ budget_id: budgetId })]} />);
|
||||
const list = makeList({ rows: [makeBudget({ budget_id: budgetId })] });
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
|
||||
const idCell = screen.getByText(budgetId);
|
||||
expect(idCell.className).not.toContain("truncate");
|
||||
expect(idCell.className).not.toMatch(/max-w-\[\d+(ch|rem|px)\]/);
|
||||
expect(idCell.className).toContain("max-w-full");
|
||||
expect(idCell.className).toContain("truncate");
|
||||
});
|
||||
|
||||
it("should keep the budget id on a single line", () => {
|
||||
const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d";
|
||||
const list = makeList({ rows: [makeBudget({ budget_id: budgetId })] });
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
|
||||
expect(screen.getByText(budgetId).className).toContain("whitespace-nowrap");
|
||||
});
|
||||
|
||||
it("should copy the budget id from the cell's copy button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d";
|
||||
const list = makeList({ rows: [makeBudget({ budget_id: budgetId })] });
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
|
||||
await user.click(screen.getByRole("button", { name: "Copy ID" }));
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith(budgetId);
|
||||
});
|
||||
|
||||
it("should offer sorting on every backend-sortable column", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={makeList()} />);
|
||||
await showColumn(user, "created_at");
|
||||
for (const field of ["budget_id", "max_budget", "tpm_limit", "rpm_limit", "created_at"]) {
|
||||
expect(screen.getByTestId(`sort-header-${field}`)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should not make the reset column sortable", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={makeList()} />);
|
||||
await showColumn(user, "budget_duration");
|
||||
const headers = screen.getAllByRole("columnheader").map((header) => header.textContent);
|
||||
expect(headers).toContain("Reset");
|
||||
expect(screen.queryByTestId("sort-header-budget_duration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should ask the list for a new sort when a sortable header is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortingChange = vi.fn();
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={makeList({ onSortingChange })} />);
|
||||
await user.click(screen.getByTestId("sort-header-max_budget"));
|
||||
expect(onSortingChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show n/a for missing rate limits and Unlimited for a missing max budget", () => {
|
||||
renderWithProviders(
|
||||
<BudgetTable {...defaultProps} budgets={[makeBudget({ max_budget: null, tpm_limit: null, rpm_limit: null })]} />,
|
||||
);
|
||||
const list = makeList({ rows: [makeBudget({ max_budget: null, tpm_limit: null, rpm_limit: null })] });
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
|
||||
expect(screen.getAllByText("n/a")).toHaveLength(2);
|
||||
expect(screen.getByText("Unlimited")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should sort budgets by updated_at descending", () => {
|
||||
const budgets = [
|
||||
makeBudget({ budget_id: "budget-old", updated_at: "2024-01-01T00:00:00Z" }),
|
||||
makeBudget({ budget_id: "budget-new", updated_at: "2024-06-01T00:00:00Z" }),
|
||||
];
|
||||
renderWithProviders(<BudgetTable {...defaultProps} budgets={budgets} />);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(within(rows[0]).getByText("budget-new")).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText("budget-old")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onEditClick from the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BudgetTable {...defaultProps} />);
|
||||
const list = makeList();
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
|
||||
await user.click(screen.getByTestId("budget-actions-budget-1"));
|
||||
await user.click(await screen.findByTestId("budget-action-edit"));
|
||||
expect(defaultProps.onEditClick).toHaveBeenCalledWith(defaultProps.budgets[0]);
|
||||
expect(defaultProps.onEditClick).toHaveBeenCalledWith(list.rows[0]);
|
||||
});
|
||||
|
||||
it("should call onDeleteClick from the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BudgetTable {...defaultProps} />);
|
||||
const list = makeList();
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
|
||||
await user.click(screen.getByTestId("budget-actions-budget-1"));
|
||||
await user.click(await screen.findByTestId("budget-action-delete"));
|
||||
expect(defaultProps.onDeleteClick).toHaveBeenCalledWith(defaultProps.budgets[0]);
|
||||
expect(defaultProps.onDeleteClick).toHaveBeenCalledWith(list.rows[0]);
|
||||
});
|
||||
|
||||
it("should not render the actions menu when the user cannot modify budgets", () => {
|
||||
renderWithProviders(<BudgetTable {...defaultProps} canModify={false} />);
|
||||
renderWithProviders(<BudgetTable {...defaultProps} canModify={false} list={makeList()} />);
|
||||
expect(screen.queryByTestId("budget-actions-budget-1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show skeleton rows when loading", () => {
|
||||
renderWithProviders(<BudgetTable {...defaultProps} budgets={[]} isLoading />);
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={makeList({ rows: [], isLoading: true })} />);
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show the empty state when there are no budgets", () => {
|
||||
renderWithProviders(<BudgetTable {...defaultProps} budgets={[]} />);
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={makeList({ rows: [], rowCount: 0 })} />);
|
||||
expect(screen.getByText("No budgets yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should tell the user their search matched nothing rather than that no budgets exist", () => {
|
||||
const list = makeList({ rows: [], rowCount: 0, searchValue: "nope" });
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
|
||||
expect(screen.getByText("No matching budgets")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render an access-denied state for a 403 instead of an empty table", () => {
|
||||
const error = new ApiError("Only proxy admins can view budgets", 403, FORBIDDEN_PROBLEM);
|
||||
const list = makeList({ rows: [], rowCount: 0, error });
|
||||
const { container } = renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
|
||||
expect(screen.getByText("You do not have access to budgets")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No budgets yet")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".lucide-shield-alert")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should surface the problem detail for a non-403 failure", () => {
|
||||
const error = new ApiError("budget store unavailable", 500, null);
|
||||
const list = makeList({ rows: [], rowCount: 0, error });
|
||||
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
|
||||
expect(screen.getByText("Could not load budgets")).toBeInTheDocument();
|
||||
expect(screen.getByText("budget store unavailable")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,55 +1,271 @@
|
|||
"use client";
|
||||
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Inbox, ShieldAlert } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
import {
|
||||
BUDGET_DURATION_FILTER_OPTIONS,
|
||||
BUDGET_DURATION_UNSET,
|
||||
type CreatedAtFilterValue,
|
||||
type MaxBudgetFilterValue,
|
||||
} from "@/app/(dashboard)/hooks/budgets/budgetFilters";
|
||||
import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
import type { ResourceListResult } from "@/app/(dashboard)/hooks/common/useResourceList";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFilterDrawer,
|
||||
DataTableFilterField,
|
||||
DataTableToolbar,
|
||||
type FilterDraft,
|
||||
} from "@/components/shared/DataTable";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
||||
import { getBudgetTableColumns } from "./BudgetTableColumns";
|
||||
import { BUDGET_TABLE_HIDDEN_COLUMNS, getBudgetTableColumns } from "./BudgetTableColumns";
|
||||
|
||||
interface BudgetTableProps {
|
||||
budgets: budgetItem[];
|
||||
isLoading: boolean;
|
||||
list: ResourceListResult<budgetItem>;
|
||||
canModify: boolean;
|
||||
onEditClick: (budget: budgetItem) => void;
|
||||
onDeleteClick: (budget: budgetItem) => void;
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
const PAGE_SIZE_OPTIONS = [25, 50, 100];
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
budget_duration: "Reset",
|
||||
max_budget: "Max Budget",
|
||||
created_at: "Created",
|
||||
};
|
||||
|
||||
const durationLabel = (value: string): string =>
|
||||
BUDGET_DURATION_FILTER_OPTIONS.find((option) => option.value === value)?.label ?? value;
|
||||
|
||||
const formatFilterValue = (columnId: string, value: unknown): string => {
|
||||
if (columnId === "budget_duration") {
|
||||
return (Array.isArray(value) ? value : []).map((entry) => durationLabel(String(entry))).join(", ");
|
||||
}
|
||||
if (columnId === "max_budget") {
|
||||
const { min, max, unlimitedOnly } = (value ?? {}) as MaxBudgetFilterValue;
|
||||
return unlimitedOnly === true ? "Unlimited only" : `${min ? `$${min}` : "any"} to ${max ? `$${max}` : "any"}`;
|
||||
}
|
||||
if (columnId === "created_at") {
|
||||
const { from, to } = (value ?? {}) as CreatedAtFilterValue;
|
||||
return `${from || "any"} to ${to || "any"}`;
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
/** The drawer keeps any non-empty object as an active filter, so collapse a blank draft to nothing. */
|
||||
const normalizeMaxBudget = (draft: MaxBudgetFilterValue): MaxBudgetFilterValue | undefined => {
|
||||
if (draft.unlimitedOnly === true) {
|
||||
return { unlimitedOnly: true };
|
||||
}
|
||||
const min = draft.min?.trim() ?? "";
|
||||
const max = draft.max?.trim() ?? "";
|
||||
if (min === "" && max === "") {
|
||||
return undefined;
|
||||
}
|
||||
return { ...(min === "" ? {} : { min }), ...(max === "" ? {} : { max }) };
|
||||
};
|
||||
|
||||
const normalizeCreatedAt = (draft: CreatedAtFilterValue): CreatedAtFilterValue | undefined => {
|
||||
const from = draft.from ?? "";
|
||||
const to = draft.to ?? "";
|
||||
if (from === "" && to === "") {
|
||||
return undefined;
|
||||
}
|
||||
return { ...(from === "" ? {} : { from }), ...(to === "" ? {} : { to }) };
|
||||
};
|
||||
|
||||
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No budgets yet</div>
|
||||
<div className="text-sm font-medium text-foreground">{hasQuery ? "No matching budgets" : "No budgets yet"}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Create a budget to set spend, TPM and RPM limits for customers.
|
||||
{hasQuery
|
||||
? "No budget matches your search or filters."
|
||||
: "Create a budget to set spend, TPM and RPM limits for customers."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BudgetTable: React.FC<BudgetTableProps> = ({ budgets, isLoading, canModify, onEditClick, onDeleteClick }) => {
|
||||
const rows = useMemo(
|
||||
() => [...budgets].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()),
|
||||
[budgets],
|
||||
function ErrorState({ error }: { error: Error }) {
|
||||
const forbidden = error instanceof ApiError && error.status === 403;
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<ShieldAlert className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{forbidden ? "You do not have access to budgets" : "Could not load budgets"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{forbidden ? "Ask a proxy admin to grant you the admin viewer role." : error.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Not set" and the concrete durations are exclusive; see serializeBudgetFilters for why. */
|
||||
function DurationFilter({ selected, onChange }: { selected: string[]; onChange: (selected: string[]) => void }) {
|
||||
const toggle = (value: string, checked: boolean): void => {
|
||||
if (!checked) {
|
||||
onChange(selected.filter((entry) => entry !== value));
|
||||
return;
|
||||
}
|
||||
const kept = value === BUDGET_DURATION_UNSET ? [] : selected.filter((entry) => entry !== BUDGET_DURATION_UNSET);
|
||||
onChange([...kept, value]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{BUDGET_DURATION_FILTER_OPTIONS.map((option) => (
|
||||
<Label key={option.value} className="font-normal">
|
||||
<Checkbox
|
||||
checked={selected.includes(option.value)}
|
||||
onCheckedChange={(checked) => toggle(option.value, checked === true)}
|
||||
data-testid={`budget-filter-duration-${option.value}`}
|
||||
/>
|
||||
{option.label}
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetFilterFields({ get, set }: FilterDraft) {
|
||||
const maxBudget = (get("max_budget") as MaxBudgetFilterValue | undefined) ?? {};
|
||||
const created = (get("created_at") as CreatedAtFilterValue | undefined) ?? {};
|
||||
const unlimitedOnly = maxBudget.unlimitedOnly === true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTableFilterField label="Reset">
|
||||
<DurationFilter
|
||||
selected={(get("budget_duration") as string[] | undefined) ?? []}
|
||||
onChange={(selected) => set("budget_duration", selected)}
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Max Budget (USD)">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={maxBudget.min ?? ""}
|
||||
disabled={unlimitedOnly}
|
||||
onChange={(event) => set("max_budget", normalizeMaxBudget({ ...maxBudget, min: event.target.value }))}
|
||||
placeholder="Min"
|
||||
aria-label="Minimum max budget"
|
||||
data-testid="budget-filter-max-budget-min"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={maxBudget.max ?? ""}
|
||||
disabled={unlimitedOnly}
|
||||
onChange={(event) => set("max_budget", normalizeMaxBudget({ ...maxBudget, max: event.target.value }))}
|
||||
placeholder="Max"
|
||||
aria-label="Maximum max budget"
|
||||
data-testid="budget-filter-max-budget-max"
|
||||
/>
|
||||
</div>
|
||||
<Label className="mt-1 font-normal">
|
||||
<Checkbox
|
||||
checked={unlimitedOnly}
|
||||
onCheckedChange={(checked) => set("max_budget", normalizeMaxBudget({ unlimitedOnly: checked === true }))}
|
||||
data-testid="budget-filter-max-budget-unlimited"
|
||||
/>
|
||||
Unlimited only
|
||||
</Label>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Created">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={created.from ?? ""}
|
||||
onChange={(event) => set("created_at", normalizeCreatedAt({ ...created, from: event.target.value }))}
|
||||
aria-label="Created from"
|
||||
data-testid="budget-filter-created-from"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={created.to ?? ""}
|
||||
onChange={(event) => set("created_at", normalizeCreatedAt({ ...created, to: event.target.value }))}
|
||||
aria-label="Created to"
|
||||
data-testid="budget-filter-created-to"
|
||||
/>
|
||||
</div>
|
||||
</DataTableFilterField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const BudgetTable: React.FC<BudgetTableProps> = ({ list, canModify, onEditClick, onDeleteClick }) => {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getBudgetTableColumns({ canModify, onEditClick, onDeleteClick }),
|
||||
[canModify, onEditClick, onDeleteClick],
|
||||
);
|
||||
|
||||
const hasQuery = list.searchValue.trim() !== "" || list.columnFilters.length > 0;
|
||||
const emptyMessage = list.error === null ? <EmptyState hasQuery={hasQuery} /> : <ErrorState error={list.error} />;
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={rows}
|
||||
data={list.rows}
|
||||
columns={columns}
|
||||
getRowId={(budget, index) => budget.budget_id || String(index)}
|
||||
isLoading={isLoading}
|
||||
defaultColumnVisibility={BUDGET_TABLE_HIDDEN_COLUMNS}
|
||||
fillHeight
|
||||
sortingMode="server"
|
||||
sorting={list.sorting}
|
||||
onSortingChange={list.onSortingChange}
|
||||
paginationMode="server"
|
||||
pagination={list.pagination}
|
||||
onPaginationChange={list.onPaginationChange}
|
||||
rowCount={list.rowCount}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
filterMode="server"
|
||||
columnFilters={list.columnFilters}
|
||||
onColumnFiltersChange={list.onColumnFiltersChange}
|
||||
isLoading={list.isLoading}
|
||||
loadingMessage="Loading budgets…"
|
||||
noDataMessage={<EmptyState />}
|
||||
noDataMessage={emptyMessage}
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchValue={list.searchValue}
|
||||
onSearchChange={list.onSearchChange}
|
||||
searchPlaceholder="Search by budget ID…"
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
onRefresh={list.refetch}
|
||||
isRefreshing={list.isFetching}
|
||||
filterLabels={FILTER_LABELS}
|
||||
formatFilterValue={formatFilterValue}
|
||||
/>
|
||||
<DataTableFilterDrawer
|
||||
table={table}
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
title="Filters"
|
||||
description="Narrow down your budgets"
|
||||
>
|
||||
{(draft) => <BudgetFilterFields {...draft} />}
|
||||
</DataTableFilterDrawer>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ColumnDef, FilterFn } from "@tanstack/react-table";
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -15,13 +17,29 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
function RateLimitCell({ value }: { value: number | null }) {
|
||||
/**
|
||||
* Filtering happens on the server, so this never runs as a predicate. It exists to override
|
||||
* TanStack's auto-remove heuristic, which infers a filter shape from the column's first cell
|
||||
* and silently discards a filter whose value is not that shape (a range object on a numeric
|
||||
* column, for instance).
|
||||
*/
|
||||
const serverFilter: FilterFn<budgetItem> = () => true;
|
||||
serverFilter.autoRemove = () => false;
|
||||
|
||||
function RateLimitCell({ value }: { value: number | null | undefined }) {
|
||||
if (value == null) {
|
||||
return <span className="text-muted-foreground">n/a</span>;
|
||||
}
|
||||
return <span className="tabular-nums">{value}</span>;
|
||||
}
|
||||
|
||||
function BudgetDurationCell({ value }: { value: string | null | undefined }) {
|
||||
if (!value) {
|
||||
return <span className="text-muted-foreground">Not set</span>;
|
||||
}
|
||||
return <span className="whitespace-nowrap">{getBudgetDurationLabel(value)}</span>;
|
||||
}
|
||||
|
||||
interface BudgetRowActionsProps {
|
||||
budget: budgetItem;
|
||||
onEditClick: (budget: budgetItem) => void;
|
||||
|
|
@ -57,6 +75,12 @@ function BudgetRowActions({ budget, onEditClick, onDeleteClick }: BudgetRowActio
|
|||
);
|
||||
}
|
||||
|
||||
/** Off by default so the table opens on the four columns it has always shown; the Columns menu turns them on. */
|
||||
export const BUDGET_TABLE_HIDDEN_COLUMNS: Record<string, boolean> = {
|
||||
budget_duration: false,
|
||||
created_at: false,
|
||||
};
|
||||
|
||||
interface BudgetTableColumnsDeps {
|
||||
canModify: boolean;
|
||||
onEditClick: (budget: budgetItem) => void;
|
||||
|
|
@ -72,38 +96,56 @@ export const getBudgetTableColumns = ({
|
|||
id: "budget_id",
|
||||
accessorKey: "budget_id",
|
||||
meta: { title: "Budget ID" },
|
||||
header: "Budget ID",
|
||||
size: 220,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.budget_id} variant="plain" className="max-w-full" />,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Budget ID" />,
|
||||
cell: ({ row }) => (
|
||||
<IdCell value={row.original.budget_id} variant="plain" truncate={false} copyable className="whitespace-nowrap" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
filterFn: serverFilter,
|
||||
meta: { title: "Max Budget", numeric: true },
|
||||
header: "Max Budget",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Max Budget" />,
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={2} showZero emptyText="Unlimited" />,
|
||||
},
|
||||
{
|
||||
id: "tpm_limit",
|
||||
accessorKey: "tpm_limit",
|
||||
meta: { title: "TPM", numeric: true },
|
||||
header: "TPM",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="TPM" />,
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <RateLimitCell value={row.original.tpm_limit} />,
|
||||
},
|
||||
{
|
||||
id: "rpm_limit",
|
||||
accessorKey: "rpm_limit",
|
||||
meta: { title: "RPM", numeric: true },
|
||||
header: "RPM",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="RPM" />,
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <RateLimitCell value={row.original.rpm_limit} />,
|
||||
},
|
||||
{
|
||||
id: "budget_duration",
|
||||
accessorKey: "budget_duration",
|
||||
filterFn: serverFilter,
|
||||
meta: { title: "Reset" },
|
||||
// "7d"/"30d" sort lexicographically, not chronologically, so the route does not offer it.
|
||||
enableSorting: false,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Reset" />,
|
||||
size: 110,
|
||||
cell: ({ row }) => <BudgetDurationCell value={row.original.budget_duration} />,
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
filterFn: serverFilter,
|
||||
meta: { title: "Created" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
|
||||
size: 160,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
},
|
||||
...(canModify
|
||||
? [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,217 +1,254 @@
|
|||
import { fireEvent, render, waitFor, screen } from "@testing-library/react";
|
||||
import { act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
||||
import BudgetPanel from "./budget_panel";
|
||||
|
||||
const mockBudgets = [
|
||||
{
|
||||
budget_id: "budget-1",
|
||||
max_budget: 100,
|
||||
rpm_limit: 10,
|
||||
tpm_limit: 1000,
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/budgets/useBudgets", () => ({
|
||||
useBudgets: vi.fn().mockReturnValue({ data: [], isLoading: false }),
|
||||
useDeleteBudget: vi.fn().mockReturnValue({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useCreateBudget: vi.fn().mockReturnValue({ mutateAsync: vi.fn() }),
|
||||
useUpdateBudget: vi.fn().mockReturnValue({ mutateAsync: vi.fn() }),
|
||||
const { getMock, budgetDeleteMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
budgetDeleteMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
useBudgets,
|
||||
useDeleteBudget,
|
||||
useCreateBudget,
|
||||
useUpdateBudget,
|
||||
} from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
vi.mock("@/components/networking", () => ({
|
||||
apiClient: { get: getMock },
|
||||
budgetCreateCall: vi.fn(),
|
||||
budgetUpdateCall: vi.fn(),
|
||||
budgetDeleteCall: budgetDeleteMock,
|
||||
getProxyBaseUrl: () => "",
|
||||
}));
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => ({ accessToken: "sk-test", userRole: "Admin", userId: "u1" }),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const qc = createQueryClient();
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: { success: vi.fn(), info: vi.fn(), fromBackend: vi.fn() },
|
||||
}));
|
||||
|
||||
interface BudgetSeed {
|
||||
budget_id: string;
|
||||
max_budget: number | null;
|
||||
budget_duration: string | null;
|
||||
}
|
||||
|
||||
const budgetRow = (seed: BudgetSeed) => ({
|
||||
soft_budget: null,
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 10,
|
||||
budget_reset_at: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...seed,
|
||||
});
|
||||
|
||||
const FORBIDDEN_PROBLEM = {
|
||||
type: "about:blank",
|
||||
title: "Forbidden",
|
||||
status: 403,
|
||||
detail: "Only proxy admins can view budgets",
|
||||
};
|
||||
|
||||
const DEFAULT_ROWS = [
|
||||
budgetRow({ budget_id: "ecc1869c-6231-4380-a56d-1a0be457477d", max_budget: 100, budget_duration: "30d" }),
|
||||
];
|
||||
|
||||
const respondWith = (rows: ReturnType<typeof budgetRow>[], totalCount: number) => {
|
||||
getMock.mockResolvedValue({
|
||||
data: rows,
|
||||
meta: { total_count: totalCount, page: 1, page_size: 50, total_pages: Math.ceil(totalCount / 50) },
|
||||
});
|
||||
};
|
||||
|
||||
type QueryRecord = Record<string, string | number>;
|
||||
|
||||
const queries = (): QueryRecord[] => getMock.mock.calls.map((call) => (call[1] as { query: QueryRecord }).query);
|
||||
const lastQuery = (): QueryRecord => queries()[queries().length - 1];
|
||||
const paths = (): string[] => getMock.mock.calls.map((call) => String(call[0]));
|
||||
|
||||
const renderPanel = () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<BudgetPanel accessToken="sk-test" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const openFilters = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByTestId("datatable-filters-trigger"));
|
||||
await screen.findByTestId("filter-drawer-body");
|
||||
};
|
||||
|
||||
describe("Budget Panel", () => {
|
||||
afterEach(() => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
respondWith(DEFAULT_ROWS, 1);
|
||||
});
|
||||
|
||||
it("should render the budget panel and load budgets", async () => {
|
||||
vi.mocked(useBudgets).mockReturnValue({
|
||||
data: mockBudgets,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<BudgetPanel accessToken="token-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create a budget to assign to customers.")).toBeInTheDocument();
|
||||
expect(screen.getByText("budget-1")).toBeInTheDocument();
|
||||
});
|
||||
it("loads the first page of budgets, newest first", async () => {
|
||||
renderPanel();
|
||||
await waitFor(() => expect(getMock).toHaveBeenCalled());
|
||||
expect(paths()[0]).toBe("/management/v1/budgets");
|
||||
expect(queries()[0]).toEqual({ page: 1, page_size: 50, sort: "-created_at" });
|
||||
expect(await screen.findByText("ecc1869c-6231-4380-a56d-1a0be457477d")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open delete modal from the actions menu", async () => {
|
||||
it("asks the server to sort when a sortable header is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(useBudgets).mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
budget_id: "budget-to-delete",
|
||||
max_budget: 200,
|
||||
rpm_limit: 20,
|
||||
tpm_limit: 2000,
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
renderPanel();
|
||||
await waitFor(() => expect(getMock).toHaveBeenCalled());
|
||||
|
||||
renderWithProviders(<BudgetPanel accessToken="token-123" />);
|
||||
await user.click(screen.getByTestId("sort-header-max_budget"));
|
||||
await waitFor(() => expect(lastQuery().sort).toBe("-max_budget"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("budget-to-delete")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByTestId("sort-header-max_budget"));
|
||||
await waitFor(() => expect(lastQuery().sort).toBe("max_budget"));
|
||||
|
||||
await user.click(screen.getByTestId("budget-actions-budget-to-delete"));
|
||||
await user.click(screen.getByTestId("sort-header-budget_id"));
|
||||
await waitFor(() => expect(lastQuery().sort).toBe("budget_id"));
|
||||
});
|
||||
|
||||
it("searches on budget_id with a debounced q", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await waitFor(() => expect(getMock).toHaveBeenCalled());
|
||||
|
||||
await user.type(screen.getByTestId("datatable-search"), "ecc");
|
||||
await waitFor(() => expect(lastQuery().q).toBe("ecc"));
|
||||
expect(queries().some((query) => query.q === "e" || query.q === "ec")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters by reset duration and clears it again", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await waitFor(() => expect(getMock).toHaveBeenCalled());
|
||||
|
||||
await openFilters(user);
|
||||
await user.click(screen.getByTestId("budget-filter-duration-7d"));
|
||||
await user.click(screen.getByTestId("budget-filter-duration-30d"));
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => expect(lastQuery()["filter[budget_duration][in]"]).toBe("7d,30d"));
|
||||
|
||||
await user.click(screen.getByTestId("filter-chip-remove-budget_duration"));
|
||||
await waitFor(() => expect(lastQuery()).not.toHaveProperty("filter[budget_duration][in]"));
|
||||
});
|
||||
|
||||
it("filters by budgets with no reset duration", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await waitFor(() => expect(getMock).toHaveBeenCalled());
|
||||
|
||||
await openFilters(user);
|
||||
await user.click(screen.getByTestId("budget-filter-duration-__unset__"));
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => expect(lastQuery()["filter[budget_duration][is_null]"]).toBe("true"));
|
||||
expect(lastQuery()).not.toHaveProperty("filter[budget_duration][in]");
|
||||
});
|
||||
|
||||
it("filters by a max budget range and clears it again", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await waitFor(() => expect(getMock).toHaveBeenCalled());
|
||||
|
||||
await openFilters(user);
|
||||
await user.type(screen.getByTestId("budget-filter-max-budget-min"), "10");
|
||||
await user.type(screen.getByTestId("budget-filter-max-budget-max"), "500");
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => expect(lastQuery()["filter[max_budget][gte]"]).toBe("10"));
|
||||
expect(lastQuery()["filter[max_budget][lte]"]).toBe("500");
|
||||
|
||||
await user.click(screen.getByTestId("datatable-clear-filters"));
|
||||
await waitFor(() => expect(lastQuery()).not.toHaveProperty("filter[max_budget][gte]"));
|
||||
expect(lastQuery()).not.toHaveProperty("filter[max_budget][lte]");
|
||||
});
|
||||
|
||||
it("filters to unlimited budgets only", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await waitFor(() => expect(getMock).toHaveBeenCalled());
|
||||
|
||||
await openFilters(user);
|
||||
await user.type(screen.getByTestId("budget-filter-max-budget-min"), "10");
|
||||
await user.click(screen.getByTestId("budget-filter-max-budget-unlimited"));
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => expect(lastQuery()["filter[max_budget][is_null]"]).toBe("true"));
|
||||
expect(lastQuery()).not.toHaveProperty("filter[max_budget][gte]");
|
||||
});
|
||||
|
||||
it("filters by a created date range covering whole local days", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await waitFor(() => expect(getMock).toHaveBeenCalled());
|
||||
|
||||
await openFilters(user);
|
||||
await user.type(screen.getByTestId("budget-filter-created-from"), "2026-01-05");
|
||||
await user.type(screen.getByTestId("budget-filter-created-to"), "2026-01-06");
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastQuery()["filter[created_at][gte]"]).toBe(new Date("2026-01-05T00:00:00.000").toISOString()),
|
||||
);
|
||||
expect(lastQuery()["filter[created_at][lte]"]).toBe(new Date("2026-01-06T23:59:59.999").toISOString());
|
||||
});
|
||||
|
||||
it("pages through the results and changes page size", async () => {
|
||||
const user = userEvent.setup();
|
||||
respondWith(DEFAULT_ROWS, 400);
|
||||
renderPanel();
|
||||
await waitFor(() => expect(getMock).toHaveBeenCalled());
|
||||
|
||||
await user.click(screen.getByTestId("pagination-next"));
|
||||
await waitFor(() => expect(lastQuery().page).toBe(2));
|
||||
expect(lastQuery().page_size).toBe(50);
|
||||
|
||||
await user.click(screen.getByTestId("pagination-page-size"));
|
||||
await user.click(await screen.findByRole("option", { name: "25" }));
|
||||
await waitFor(() => expect(lastQuery().page_size).toBe(25));
|
||||
});
|
||||
|
||||
it("renders an access-denied state when the route rejects the caller", async () => {
|
||||
getMock.mockRejectedValue(new ApiError("Only proxy admins can view budgets", 403, FORBIDDEN_PROBLEM));
|
||||
renderPanel();
|
||||
expect(await screen.findByText("You do not have access to budgets")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No budgets yet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deletes a budget from the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
budgetDeleteMock.mockResolvedValue(undefined);
|
||||
renderPanel();
|
||||
await screen.findByText("ecc1869c-6231-4380-a56d-1a0be457477d");
|
||||
|
||||
await user.click(screen.getByTestId("budget-actions-ecc1869c-6231-4380-a56d-1a0be457477d"));
|
||||
await user.click(await screen.findByTestId("budget-action-delete"));
|
||||
await screen.findByText("Delete Budget?");
|
||||
await user.click(screen.getByRole("button", { name: /^delete$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Delete Budget?")).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(budgetDeleteMock).toHaveBeenCalledWith("sk-test", "ecc1869c-6231-4380-a56d-1a0be457477d"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should successfully delete a budget", async () => {
|
||||
it("refetches the current page after a delete", async () => {
|
||||
const user = userEvent.setup();
|
||||
const deleteMutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(useBudgets).mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
budget_id: "budget-to-delete",
|
||||
max_budget: 200,
|
||||
rpm_limit: 20,
|
||||
tpm_limit: 2000,
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
vi.mocked(useDeleteBudget).mockReturnValue({
|
||||
mutateAsync: deleteMutateAsync,
|
||||
isPending: false,
|
||||
} as any);
|
||||
budgetDeleteMock.mockResolvedValue(undefined);
|
||||
renderPanel();
|
||||
await screen.findByText("ecc1869c-6231-4380-a56d-1a0be457477d");
|
||||
const before = getMock.mock.calls.length;
|
||||
|
||||
renderWithProviders(<BudgetPanel accessToken="token-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("budget-to-delete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId("budget-actions-budget-to-delete"));
|
||||
await user.click(screen.getByTestId("budget-actions-ecc1869c-6231-4380-a56d-1a0be457477d"));
|
||||
await user.click(await screen.findByTestId("budget-action-delete"));
|
||||
await screen.findByText("Delete Budget?");
|
||||
await user.click(screen.getByRole("button", { name: /^delete$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Delete Budget?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByRole("button", { name: /delete/i });
|
||||
act(() => {
|
||||
fireEvent.click(confirmButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteMutateAsync).toHaveBeenCalledWith("budget-to-delete");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render empty state without crashing", async () => {
|
||||
vi.mocked(useBudgets).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<BudgetPanel accessToken="token-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create a budget to assign to customers.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle delete error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const deleteMutateAsync = vi.fn().mockRejectedValue(new Error("Delete failed"));
|
||||
vi.mocked(useBudgets).mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
budget_id: "budget-to-delete",
|
||||
max_budget: 200,
|
||||
rpm_limit: 20,
|
||||
tpm_limit: 2000,
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
vi.mocked(useDeleteBudget).mockReturnValue({
|
||||
mutateAsync: deleteMutateAsync,
|
||||
isPending: false,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<BudgetPanel accessToken="token-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("budget-to-delete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId("budget-actions-budget-to-delete"));
|
||||
await user.click(await screen.findByTestId("budget-action-delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Delete Budget?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByRole("button", { name: /delete/i });
|
||||
act(() => {
|
||||
fireEvent.click(confirmButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteMutateAsync).toHaveBeenCalledWith("budget-to-delete");
|
||||
});
|
||||
});
|
||||
|
||||
it("should open edit modal from the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(useBudgets).mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
budget_id: "budget-to-edit",
|
||||
max_budget: 300,
|
||||
rpm_limit: 30,
|
||||
tpm_limit: 3000,
|
||||
updated_at: "2024-01-03T00:00:00Z",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<BudgetPanel accessToken="token-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("budget-to-edit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId("budget-actions-budget-to-edit"));
|
||||
await user.click(await screen.findByTestId("budget-action-edit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Edit Budget")).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => expect(getMock.mock.calls.length).toBeGreaterThan(before));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@
|
|||
*
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Plus, Wallet } from "lucide-react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { PageHeader } from "@/components/shared/PageHeader";
|
||||
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
import { useBudgetList, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
import BudgetModal from "./budget_modal";
|
||||
import BudgetTable from "./BudgetTable";
|
||||
import EditBudgetModal from "./edit_budget_modal";
|
||||
|
|
@ -31,21 +34,25 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
|
|||
// Admin Viewer follows the read-parity rule: see budgets, no writes.
|
||||
const canModify = isProxyAdminRole(userRole ?? "");
|
||||
|
||||
const { data: budgetList = [], isLoading } = useBudgets();
|
||||
const budgetList = useBudgetList();
|
||||
const deleteBudget = useDeleteBudget();
|
||||
|
||||
const handleEditCall = async (budget: budgetItem) => {
|
||||
if (accessToken == null) {
|
||||
return;
|
||||
}
|
||||
setSelectedBudget(budget);
|
||||
setIsEditModalVisible(true);
|
||||
};
|
||||
// Stable identities keep the memoized column defs stable; new ones remount every header and cell.
|
||||
const handleEditCall = useCallback(
|
||||
(budget: budgetItem) => {
|
||||
if (accessToken == null) {
|
||||
return;
|
||||
}
|
||||
setSelectedBudget(budget);
|
||||
setIsEditModalVisible(true);
|
||||
},
|
||||
[accessToken],
|
||||
);
|
||||
|
||||
const handleDeleteClick = (budget: budgetItem) => {
|
||||
const handleDeleteClick = useCallback((budget: budgetItem) => {
|
||||
setSelectedBudget(budget);
|
||||
setIsDeleteModalVisible(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!selectedBudget || accessToken == null) {
|
||||
|
|
@ -72,23 +79,34 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
|
||||
{canModify && (
|
||||
<Button size="sm" className="mb-2" onClick={() => setIsCreateModelVisible(true)}>
|
||||
+ Create Budget
|
||||
</Button>
|
||||
)}
|
||||
<Tabs defaultValue="budgets">
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="budgets" className="flex-none rounded-none px-4 py-2">
|
||||
Budgets
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="examples" className="flex-none rounded-none px-4 py-2">
|
||||
Examples
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="budgets">
|
||||
<div className="mt-6">
|
||||
<div className="flex h-full flex-col gap-4 p-6 px-12">
|
||||
<PageHeader
|
||||
icon={<Wallet className="size-5" />}
|
||||
title="Budgets"
|
||||
subtitle="Spend, TPM and RPM limits you can assign to customers."
|
||||
/>
|
||||
<Tabs defaultValue="budgets" className="min-h-0 flex-1 gap-0">
|
||||
<div className="flex items-center gap-4 border-b border-border">
|
||||
{canModify && (
|
||||
<>
|
||||
<Button onClick={() => setIsCreateModelVisible(true)}>
|
||||
<Plus className="size-4" />
|
||||
Create Budget
|
||||
</Button>
|
||||
<ToolbarSeparator className="h-6" />
|
||||
</>
|
||||
)}
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="budgets" className="flex-none px-4">
|
||||
Budgets
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="examples" className="flex-none px-4">
|
||||
Examples
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="budgets" className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col pt-6">
|
||||
<BudgetModal isModalVisible={isCreateModelVisible} setIsModalVisible={setIsCreateModelVisible} />
|
||||
{selectedBudget && (
|
||||
<EditBudgetModal
|
||||
|
|
@ -97,10 +115,8 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
|
|||
existingBudget={selectedBudget}
|
||||
/>
|
||||
)}
|
||||
<p className="mb-4 text-sm text-muted-foreground">Create a budget to assign to customers.</p>
|
||||
<BudgetTable
|
||||
budgets={budgetList}
|
||||
isLoading={isLoading}
|
||||
list={budgetList}
|
||||
canModify={canModify}
|
||||
onEditClick={handleEditCall}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
|
|
@ -122,8 +138,8 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
|
|||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="examples">
|
||||
<div className="mt-6">
|
||||
<TabsContent value="examples" className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="pt-6">
|
||||
<p className="text-base text-muted-foreground">How to use budget id</p>
|
||||
<Tabs defaultValue="assign-budget">
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { BUDGET_DURATION_UNSET, serializeBudgetFilters } from "./budgetFilters";
|
||||
|
||||
describe("serializeBudgetFilters", () => {
|
||||
it("sends nothing when no filter is active", () => {
|
||||
expect(serializeBudgetFilters([])).toEqual({});
|
||||
});
|
||||
|
||||
it("maps selected durations onto the in operator", () => {
|
||||
expect(serializeBudgetFilters([{ id: "budget_duration", value: ["7d", "30d"] }])).toEqual({
|
||||
"filter[budget_duration][in]": "7d,30d",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps 'Not set' onto is_null instead of in", () => {
|
||||
expect(serializeBudgetFilters([{ id: "budget_duration", value: [BUDGET_DURATION_UNSET] }])).toEqual({
|
||||
"filter[budget_duration][is_null]": "true",
|
||||
});
|
||||
});
|
||||
|
||||
it("never sends in alongside is_null for the same field", () => {
|
||||
const params = serializeBudgetFilters([{ id: "budget_duration", value: ["7d", BUDGET_DURATION_UNSET] }]);
|
||||
expect(params["filter[budget_duration][in]"]).toBeUndefined();
|
||||
expect(params["filter[budget_duration][is_null]"]).toBe("true");
|
||||
});
|
||||
|
||||
it("maps a max budget range onto gte and lte", () => {
|
||||
expect(serializeBudgetFilters([{ id: "max_budget", value: { min: "10", max: "250.5" } }])).toEqual({
|
||||
"filter[max_budget][gte]": "10",
|
||||
"filter[max_budget][lte]": "250.5",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends only the bound that was filled in", () => {
|
||||
expect(serializeBudgetFilters([{ id: "max_budget", value: { min: "10", max: "" } }])).toEqual({
|
||||
"filter[max_budget][gte]": "10",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps 'Unlimited only' onto is_null and drops the range", () => {
|
||||
const params = serializeBudgetFilters([{ id: "max_budget", value: { min: "10", unlimitedOnly: true } }]);
|
||||
expect(params).toEqual({ "filter[max_budget][is_null]": "true" });
|
||||
});
|
||||
|
||||
it("widens a created-at day range to cover the whole local days", () => {
|
||||
const params = serializeBudgetFilters([{ id: "created_at", value: { from: "2026-01-05", to: "2026-01-06" } }]);
|
||||
expect(params["filter[created_at][gte]"]).toBe(new Date("2026-01-05T00:00:00.000").toISOString());
|
||||
expect(params["filter[created_at][lte]"]).toBe(new Date("2026-01-06T23:59:59.999").toISOString());
|
||||
});
|
||||
|
||||
it("ignores an unparseable date rather than sending a broken bound", () => {
|
||||
expect(serializeBudgetFilters([{ id: "created_at", value: { from: "not-a-date" } }])).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores filter ids the route does not declare", () => {
|
||||
expect(serializeBudgetFilters([{ id: "spend", value: "5" }])).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { ColumnFilter, ColumnFiltersState } from "@tanstack/react-table";
|
||||
|
||||
export const BUDGET_DURATION_UNSET = "__unset__";
|
||||
|
||||
export const BUDGET_DURATION_FILTER_OPTIONS: readonly { value: string; label: string }[] = [
|
||||
{ value: "1h", label: "hourly" },
|
||||
{ value: "24h", label: "daily" },
|
||||
{ value: "7d", label: "weekly" },
|
||||
{ value: "30d", label: "monthly" },
|
||||
{ value: BUDGET_DURATION_UNSET, label: "Not set" },
|
||||
];
|
||||
|
||||
export interface MaxBudgetFilterValue {
|
||||
min?: string;
|
||||
max?: string;
|
||||
unlimitedOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatedAtFilterValue {
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
type QueryEntry = readonly [string, string];
|
||||
|
||||
const entries = (key: string, value: string): QueryEntry[] => (value === "" ? [] : [[key, value]]);
|
||||
|
||||
const asStringArray = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
||||
|
||||
const asTrimmed = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
/** The date inputs give a calendar day; the route wants an instant, so widen to the viewer's whole local day. */
|
||||
const isoAt = (day: string, time: string): string => {
|
||||
if (day === "") {
|
||||
return "";
|
||||
}
|
||||
const parsed = new Date(`${day}T${time}`);
|
||||
return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString();
|
||||
};
|
||||
|
||||
/**
|
||||
* "Not set" is exclusive with the concrete durations. The route's contract does not say how it
|
||||
* combines `in` with `is_null` on one field, and under AND semantics that pair can only match
|
||||
* nothing, so we never send both.
|
||||
*/
|
||||
const durationParams = (value: unknown): QueryEntry[] => {
|
||||
const selected = asStringArray(value);
|
||||
if (selected.includes(BUDGET_DURATION_UNSET)) {
|
||||
return [["filter[budget_duration][is_null]", "true"]];
|
||||
}
|
||||
return entries("filter[budget_duration][in]", selected.join(","));
|
||||
};
|
||||
|
||||
const maxBudgetParams = (value: unknown): QueryEntry[] => {
|
||||
const draft = asRecord(value);
|
||||
if (draft.unlimitedOnly === true) {
|
||||
return [["filter[max_budget][is_null]", "true"]];
|
||||
}
|
||||
return [
|
||||
...entries("filter[max_budget][gte]", asTrimmed(draft.min)),
|
||||
...entries("filter[max_budget][lte]", asTrimmed(draft.max)),
|
||||
];
|
||||
};
|
||||
|
||||
const createdAtParams = (value: unknown): QueryEntry[] => {
|
||||
const draft = asRecord(value);
|
||||
return [
|
||||
...entries("filter[created_at][gte]", isoAt(asTrimmed(draft.from), "00:00:00.000")),
|
||||
...entries("filter[created_at][lte]", isoAt(asTrimmed(draft.to), "23:59:59.999")),
|
||||
];
|
||||
};
|
||||
|
||||
const filterParams = (filter: ColumnFilter): QueryEntry[] => {
|
||||
switch (filter.id) {
|
||||
case "budget_duration":
|
||||
return durationParams(filter.value);
|
||||
case "max_budget":
|
||||
return maxBudgetParams(filter.value);
|
||||
case "created_at":
|
||||
return createdAtParams(filter.value);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeBudgetFilters = (filters: ColumnFiltersState): Readonly<Record<string, string>> =>
|
||||
Object.fromEntries(filters.flatMap(filterParams));
|
||||
|
|
@ -1,28 +1,46 @@
|
|||
import { useQuery, useMutation, useQueryClient, UseQueryResult } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import { getBudgetList, budgetCreateCall, budgetUpdateCall, budgetDeleteCall } from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
"use client";
|
||||
|
||||
export interface budgetItem {
|
||||
budget_id: string;
|
||||
max_budget: number | null;
|
||||
rpm_limit: number | null;
|
||||
tpm_limit: number | null;
|
||||
updated_at: string;
|
||||
}
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { SortingState } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { apiClient, budgetCreateCall, budgetUpdateCall, budgetDeleteCall } from "@/components/networking";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import { useResourceList, type ResourceListQuery, type ResourceListResult } from "../common/useResourceList";
|
||||
import { serializeBudgetFilters } from "./budgetFilters";
|
||||
|
||||
export type budgetItem = components["schemas"]["BudgetListItem"];
|
||||
|
||||
type BudgetListResponse = components["schemas"]["ListResponse_BudgetListItem_"];
|
||||
|
||||
export const BUDGET_LIST_PATH = "/management/v1/budgets";
|
||||
|
||||
export const budgetKeys = createQueryKeys("budgets");
|
||||
|
||||
export const useBudgets = (): UseQueryResult<budgetItem[]> => {
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
export const useBudgetList = (): ResourceListResult<budgetItem> => {
|
||||
const { accessToken } = useAuthorized();
|
||||
return useQuery<budgetItem[]>({
|
||||
queryKey: budgetKeys.list({}),
|
||||
queryFn: async () => {
|
||||
const data = await getBudgetList(accessToken!);
|
||||
return (data ?? []).filter((item: budgetItem | null): item is budgetItem => item != null);
|
||||
},
|
||||
|
||||
const fetchPage = useCallback(
|
||||
(query: ResourceListQuery, signal: AbortSignal): Promise<BudgetListResponse> =>
|
||||
apiClient.get<BudgetListResponse>(BUDGET_LIST_PATH, { accessToken, query, signal }),
|
||||
[accessToken],
|
||||
);
|
||||
|
||||
const listOptions = {
|
||||
queryKey: budgetKeys.lists(),
|
||||
fetchPage,
|
||||
serializeFilters: serializeBudgetFilters,
|
||||
defaultSorting: DEFAULT_SORTING,
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
enabled: Boolean(accessToken),
|
||||
});
|
||||
};
|
||||
return useResourceList<budgetItem>(listOptions);
|
||||
};
|
||||
|
||||
export const useCreateBudget = () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ColumnFiltersState } from "@tanstack/react-table";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import React, { type PropsWithChildren } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
toSortParam,
|
||||
useResourceList,
|
||||
type ResourceListPage,
|
||||
type ResourceListQuery,
|
||||
type UseResourceListOptions,
|
||||
} from "./useResourceList";
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const page = (rows: Row[], totalCount: number): ResourceListPage<Row> => ({
|
||||
data: rows,
|
||||
meta: { total_count: totalCount, page: 1, page_size: 50, total_pages: 1 },
|
||||
});
|
||||
|
||||
const noFilters = (): Readonly<Record<string, string>> => ({});
|
||||
|
||||
const calls: ResourceListQuery[] = [];
|
||||
|
||||
const renderList = (overrides: Partial<UseResourceListOptions<Row>> = {}) => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
const fetchPage = vi.fn((query: ResourceListQuery) => {
|
||||
calls.push(query);
|
||||
return Promise.resolve(page([{ id: "a" }], 3));
|
||||
});
|
||||
const options: UseResourceListOptions<Row> = {
|
||||
queryKey: ["widgets", "list"],
|
||||
fetchPage,
|
||||
serializeFilters: noFilters,
|
||||
defaultSorting: [{ id: "created_at", desc: true }],
|
||||
defaultPageSize: 50,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
return renderHook(() => useResourceList<Row>(options), { wrapper });
|
||||
};
|
||||
|
||||
const lastCall = (): ResourceListQuery => calls[calls.length - 1];
|
||||
|
||||
describe("toSortParam", () => {
|
||||
it("prefixes descending fields with a minus and joins with commas", () => {
|
||||
expect(toSortParam([{ id: "created_at", desc: true }])).toBe("-created_at");
|
||||
expect(toSortParam([{ id: "max_budget", desc: false }])).toBe("max_budget");
|
||||
expect(
|
||||
toSortParam([
|
||||
{ id: "a", desc: false },
|
||||
{ id: "b", desc: true },
|
||||
]),
|
||||
).toBe("a,-b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useResourceList", () => {
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
});
|
||||
|
||||
it("requests the first page with the default sort", async () => {
|
||||
const { result } = renderList();
|
||||
await waitFor(() => expect(result.current.rowCount).toBe(3));
|
||||
expect(lastCall()).toEqual({ page: 1, page_size: 50, sort: "-created_at" });
|
||||
});
|
||||
|
||||
it("exposes the returned rows and total count", async () => {
|
||||
const { result } = renderList();
|
||||
await waitFor(() => expect(result.current.rows).toEqual([{ id: "a" }]));
|
||||
expect(result.current.rowCount).toBe(3);
|
||||
});
|
||||
|
||||
it("does not fetch while disabled", async () => {
|
||||
const { result } = renderList({ enabled: false });
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends the new sort and returns to the first page", async () => {
|
||||
const { result } = renderList();
|
||||
await waitFor(() => expect(calls).toHaveLength(1));
|
||||
|
||||
act(() => result.current.onPaginationChange({ pageIndex: 2, pageSize: 50 }));
|
||||
await waitFor(() => expect(lastCall().page).toBe(3));
|
||||
|
||||
act(() => result.current.onSortingChange([{ id: "max_budget", desc: false }]));
|
||||
await waitFor(() => expect(lastCall().sort).toBe("max_budget"));
|
||||
expect(lastCall().page).toBe(1);
|
||||
});
|
||||
|
||||
it("omits sort entirely when nothing is sorted", async () => {
|
||||
const { result } = renderList({ defaultSorting: [] });
|
||||
await waitFor(() => expect(calls).toHaveLength(1));
|
||||
expect(result.current.sorting).toEqual([]);
|
||||
expect(lastCall()).not.toHaveProperty("sort");
|
||||
});
|
||||
|
||||
it("debounces the search into a single trimmed q and returns to the first page", async () => {
|
||||
const { result } = renderList();
|
||||
await waitFor(() => expect(calls).toHaveLength(1));
|
||||
|
||||
act(() => result.current.onPaginationChange({ pageIndex: 1, pageSize: 50 }));
|
||||
await waitFor(() => expect(lastCall().page).toBe(2));
|
||||
|
||||
act(() => result.current.onSearchChange("bud"));
|
||||
act(() => result.current.onSearchChange("budg "));
|
||||
|
||||
await waitFor(() => expect(lastCall().q).toBe("budg"));
|
||||
expect(lastCall().page).toBe(1);
|
||||
expect(calls.some((call) => call.q === "bud")).toBe(false);
|
||||
});
|
||||
|
||||
it("stops sending q once the search box is cleared", async () => {
|
||||
const { result } = renderList();
|
||||
act(() => result.current.onSearchChange("budget"));
|
||||
await waitFor(() => expect(lastCall().q).toBe("budget"));
|
||||
|
||||
act(() => result.current.onSearchChange(""));
|
||||
await waitFor(() => expect(lastCall()).not.toHaveProperty("q"));
|
||||
});
|
||||
|
||||
it("merges serialized filters into the request and returns to the first page", async () => {
|
||||
const serializeFilters = (filters: ColumnFiltersState): Readonly<Record<string, string>> =>
|
||||
filters.length === 0 ? {} : { "filter[colour][in]": String(filters[0].value) };
|
||||
const { result } = renderList({ serializeFilters });
|
||||
await waitFor(() => expect(calls).toHaveLength(1));
|
||||
|
||||
act(() => result.current.onPaginationChange({ pageIndex: 3, pageSize: 50 }));
|
||||
await waitFor(() => expect(lastCall().page).toBe(4));
|
||||
|
||||
act(() => result.current.onColumnFiltersChange([{ id: "colour", value: "red" }]));
|
||||
await waitFor(() => expect(lastCall()["filter[colour][in]"]).toBe("red"));
|
||||
expect(lastCall().page).toBe(1);
|
||||
|
||||
act(() => result.current.onColumnFiltersChange([]));
|
||||
await waitFor(() => expect(lastCall()).not.toHaveProperty("filter[colour][in]"));
|
||||
});
|
||||
|
||||
it("sends the requested page size", async () => {
|
||||
const { result } = renderList();
|
||||
await waitFor(() => expect(calls).toHaveLength(1));
|
||||
|
||||
act(() => result.current.onPaginationChange({ pageIndex: 0, pageSize: 25 }));
|
||||
await waitFor(() => expect(lastCall().page_size).toBe(25));
|
||||
});
|
||||
|
||||
it("surfaces a failed page as an error instead of empty rows", async () => {
|
||||
const fetchPage = vi.fn(() => Promise.reject(new Error("boom")));
|
||||
const { result } = renderList({ fetchPage });
|
||||
await waitFor(() => expect(result.current.error?.message).toBe("boom"));
|
||||
expect(result.current.rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
|
||||
import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
|
||||
export type ResourceListQuery = Readonly<Record<string, string | number>>;
|
||||
|
||||
/** The management list envelope. The generated response models are monomorphic, so only `data` is generic here. */
|
||||
export type ResourceListMeta = components["schemas"]["ListMeta"];
|
||||
|
||||
export interface ResourceListPage<TRow> {
|
||||
data: TRow[];
|
||||
meta: ResourceListMeta;
|
||||
}
|
||||
|
||||
export interface UseResourceListOptions<TRow> {
|
||||
/** Prefix every list variant hangs off, so invalidating the resource root refetches whichever page is on screen. */
|
||||
queryKey: readonly unknown[];
|
||||
fetchPage: (query: ResourceListQuery, signal: AbortSignal) => Promise<ResourceListPage<TRow>>;
|
||||
/** Must be referentially stable; it feeds the query key. */
|
||||
serializeFilters: (filters: ColumnFiltersState) => Readonly<Record<string, string>>;
|
||||
defaultSorting: SortingState;
|
||||
defaultPageSize: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ResourceListResult<TRow> {
|
||||
rows: TRow[];
|
||||
rowCount: number;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
|
||||
sorting: SortingState;
|
||||
onSortingChange: OnChangeFn<SortingState>;
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
columnFilters: ColumnFiltersState;
|
||||
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/** JSON:API sort form: comma separated fields, `-` prefix for descending. */
|
||||
export const toSortParam = (sorting: SortingState): string =>
|
||||
sorting.map((entry) => (entry.desc ? `-${entry.id}` : entry.id)).join(",");
|
||||
|
||||
/**
|
||||
* State container for a table whose sorting, paging, search and filtering all run
|
||||
* on the server. It owns those four pieces of state, folds them into one JSON:API
|
||||
* query, and returns the exact props DataTable's server modes want.
|
||||
*
|
||||
* Empty parameters are dropped rather than sent blank because the management
|
||||
* routes reject query params they do not declare.
|
||||
*/
|
||||
export function useResourceList<TRow>(options: UseResourceListOptions<TRow>): ResourceListResult<TRow> {
|
||||
const { queryKey, fetchPage, serializeFilters, defaultSorting, defaultPageSize, enabled } = options;
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSorting);
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: defaultPageSize });
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(searchValue, { wait: DEBOUNCE_WAIT_MS });
|
||||
|
||||
const query = useMemo<ResourceListQuery>(() => {
|
||||
const sort = toSortParam(sorting);
|
||||
const search = debouncedSearch.trim();
|
||||
return {
|
||||
page: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
...(sort === "" ? {} : { sort }),
|
||||
...(search === "" ? {} : { q: search }),
|
||||
...serializeFilters(columnFilters),
|
||||
};
|
||||
}, [sorting, pagination.pageIndex, pagination.pageSize, debouncedSearch, columnFilters, serializeFilters]);
|
||||
|
||||
const queryOptions: UseQueryOptions<ResourceListPage<TRow>, Error, ResourceListPage<TRow>, readonly unknown[]> = {
|
||||
queryKey: [...queryKey, query],
|
||||
queryFn: ({ signal }) => fetchPage(query, signal),
|
||||
enabled,
|
||||
placeholderData: (previous) => previous,
|
||||
};
|
||||
const { data, isLoading, isFetching, error, refetch: refetchQuery } = useQuery(queryOptions);
|
||||
|
||||
const toFirstPage = useCallback(() => setPagination((previous) => ({ ...previous, pageIndex: 0 })), []);
|
||||
|
||||
const onSortingChange = useCallback<OnChangeFn<SortingState>>(
|
||||
(updater) => {
|
||||
setSorting(updater);
|
||||
toFirstPage();
|
||||
},
|
||||
[toFirstPage],
|
||||
);
|
||||
|
||||
const onColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>(
|
||||
(updater) => {
|
||||
setColumnFilters(updater);
|
||||
toFirstPage();
|
||||
},
|
||||
[toFirstPage],
|
||||
);
|
||||
|
||||
const onSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearchValue(value);
|
||||
toFirstPage();
|
||||
},
|
||||
[toFirstPage],
|
||||
);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
void refetchQuery();
|
||||
}, [refetchQuery]);
|
||||
|
||||
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||
|
||||
return {
|
||||
rows,
|
||||
rowCount: data?.meta.total_count ?? 0,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
pagination,
|
||||
onPaginationChange: setPagination,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
};
|
||||
}
|
||||
|
|
@ -17,9 +17,25 @@ import {
|
|||
formatKeywords,
|
||||
parseSkillSource,
|
||||
isValidSubPath,
|
||||
buildMarketplaceSettingsSnippet,
|
||||
} from "./helpers";
|
||||
import { MarketplacePluginEntry, PluginSource } from "./types";
|
||||
|
||||
describe("buildMarketplaceSettingsSnippet", () => {
|
||||
it("nests the url under a source object so Claude Code accepts the marketplace", () => {
|
||||
expect(JSON.parse(buildMarketplaceSettingsSnippet("https://proxy.example.com"))).toEqual({
|
||||
extraKnownMarketplaces: {
|
||||
"my-org": {
|
||||
source: {
|
||||
source: "url",
|
||||
url: "https://proxy.example.com/claude-code/marketplace.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatInstallCommand", () => {
|
||||
it("formats github source with repo", () => {
|
||||
const source: PluginSource = { source: "github", repo: "org/repo" };
|
||||
|
|
|
|||
|
|
@ -176,6 +176,27 @@ export const parseSkillSource = (rawUrl: string, subPath?: string): SkillSourceP
|
|||
return parseRawGitSource(url, subPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the `~/.claude/settings.json` snippet that registers the proxy as a marketplace.
|
||||
* Claude Code expects `extraKnownMarketplaces.<name>.source` to be a source object, not a
|
||||
* bare `"url"` string, so the url/source pair is nested one level deeper.
|
||||
*/
|
||||
export const buildMarketplaceSettingsSnippet = (proxyOrigin: string): string =>
|
||||
JSON.stringify(
|
||||
{
|
||||
extraKnownMarketplaces: {
|
||||
"my-org": {
|
||||
source: {
|
||||
source: "url",
|
||||
url: `${proxyOrigin}/claude-code/marketplace.json`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate install command for Claude Code CLI
|
||||
* Format: /plugin marketplace add org/repo OR /plugin marketplace add url
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { ArrowLeftOutlined, CopyOutlined, CheckOutlined, LinkOutlined } from "@ant-design/icons";
|
||||
import { formatInstallCommand } from "./helpers";
|
||||
import { buildMarketplaceSettingsSnippet, formatInstallCommand } from "./helpers";
|
||||
import { Plugin } from "./types";
|
||||
|
||||
interface SkillDetailProps {
|
||||
|
|
@ -31,6 +31,10 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
|
||||
const installCommand = formatInstallCommand(skill);
|
||||
|
||||
const settingsSnippet = buildMarketplaceSettingsSnippet(
|
||||
typeof window !== "undefined" ? window.location.origin : "<proxy-url>",
|
||||
);
|
||||
|
||||
const detailRows = [
|
||||
...(skill.category ? [{ property: "Category", value: skill.category }] : []),
|
||||
...(skill.domain ? [{ property: "Domain", value: skill.domain }] : []),
|
||||
|
|
@ -298,21 +302,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
>
|
||||
<span style={{ fontSize: 13, color: "#3c4043", fontWeight: 500 }}>~/.claude/settings.json</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const snippet = JSON.stringify(
|
||||
{
|
||||
extraKnownMarketplaces: {
|
||||
"my-org": {
|
||||
source: "url",
|
||||
url: `${typeof window !== "undefined" ? window.location.origin : ""}/claude-code/marketplace.json`,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
copyToClipboard(snippet, "settings");
|
||||
}}
|
||||
onClick={() => copyToClipboard(settingsSnippet, "settings")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
|
@ -339,18 +329,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
backgroundColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(
|
||||
{
|
||||
extraKnownMarketplaces: {
|
||||
"my-org": {
|
||||
source: "url",
|
||||
url: `${typeof window !== "undefined" ? window.location.origin : "<proxy-url>"}/claude-code/marketplace.json`,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
{settingsSnippet}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -625,6 +625,44 @@ describe("DataTable layout", () => {
|
|||
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
|
||||
expect(scroller.style.maxHeight).toBe("240px");
|
||||
});
|
||||
|
||||
it("caps fillHeight at the parent's height instead of stretching to it, so a short table stays short", () => {
|
||||
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} fillHeight />);
|
||||
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
|
||||
const frame = scroller.parentElement as HTMLElement;
|
||||
const outer = frame.parentElement as HTMLElement;
|
||||
|
||||
// A ceiling, not a stretch: flex-1 here would hold the footer at the bottom on a two-row table.
|
||||
expect(outer.className).toContain("max-h-full");
|
||||
expect(outer.className).not.toContain("flex-1");
|
||||
expect(frame.className).not.toContain("flex-1");
|
||||
expect(scroller.className).not.toContain("flex-1");
|
||||
|
||||
expect(outer.className).toContain("flex-col");
|
||||
expect(frame.className).toContain("flex-col");
|
||||
expect(scroller.className).toContain("min-h-0");
|
||||
expect(scroller.className).toContain("overflow-auto");
|
||||
expect(scroller.style.maxHeight).toBe("");
|
||||
// Without this the Table primitive's own overflow container captures the sticky header.
|
||||
expect(scroller.className).toContain("[&_[data-slot=table-container]]:overflow-visible");
|
||||
|
||||
const thead = container.querySelector("thead") as HTMLElement;
|
||||
expect(thead.className).toContain("sticky");
|
||||
// Rows pass under the header, so the semi-transparent row tint alone would let them show through.
|
||||
expect(thead.className).toContain("bg-background");
|
||||
});
|
||||
|
||||
it("leaves the default layout untouched when neither height mode is set", () => {
|
||||
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} />);
|
||||
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
|
||||
|
||||
expect(scroller.className).toContain("overflow-x-auto");
|
||||
expect(scroller.className).not.toContain("min-h-0");
|
||||
expect(scroller.style.maxHeight).toBe("");
|
||||
expect((scroller.parentElement as HTMLElement).className).not.toContain("flex-col");
|
||||
expect(container.querySelector("thead")?.className).not.toContain("sticky");
|
||||
expect(container.querySelector("thead")?.className).not.toContain("bg-background");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataTable misconfiguration guards", () => {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,22 @@ const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role=checkbox
|
|||
|
||||
const noop = () => {};
|
||||
|
||||
/**
|
||||
* Height-filling mode. The table still sizes to its rows; the parent's height is only a ceiling, so
|
||||
* a short table keeps its footer under the last row and a long one scrolls its rows instead of the
|
||||
* page. `table-container` is the Table primitive's own overflow-x wrapper; left as a scroll box it
|
||||
* captures the sticky header and the header scrolls away with the rows. And rows pass under that
|
||||
* header, which the semi-transparent header row tint alone would not hide.
|
||||
*/
|
||||
const FILL_CLASSES = {
|
||||
outer: "flex max-h-full min-h-0 flex-col",
|
||||
frame: "flex min-h-0 flex-col",
|
||||
body: "min-h-0 [&_[data-slot=table-container]]:overflow-visible",
|
||||
header: "bg-background",
|
||||
} as const;
|
||||
|
||||
const NO_FILL_CLASSES = { outer: "", frame: "", body: "", header: "" } as const;
|
||||
|
||||
export class DataTableConfigError extends Error {
|
||||
constructor(messages: readonly string[]) {
|
||||
super(`DataTable misconfiguration:\n- ${messages.join("\n- ")}`);
|
||||
|
|
@ -538,6 +554,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
|
|||
rowClassName,
|
||||
renderSubComponent,
|
||||
maxBodyHeight,
|
||||
fillHeight = false,
|
||||
size = "default",
|
||||
toolbar,
|
||||
paginationSlot,
|
||||
|
|
@ -548,7 +565,8 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
|
|||
|
||||
const rows = table.getRowModel().rows;
|
||||
const visibleColumnCount = table.getVisibleLeafColumns().length;
|
||||
const stickyHeader = maxBodyHeight !== undefined;
|
||||
const stickyHeader = maxBodyHeight !== undefined || fillHeight;
|
||||
const fill = fillHeight ? FILL_CLASSES : NO_FILL_CLASSES;
|
||||
const tableStyle = enableColumnResizing ? { width: table.getTotalSize(), minWidth: "100%" } : undefined;
|
||||
|
||||
const renderPagination = (): React.ReactNode => {
|
||||
|
|
@ -604,15 +622,15 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
|
|||
const paginationNode = renderPagination();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{toolbar !== undefined && <div className="border-b border-border px-4 py-3">{toolbar(table)}</div>}
|
||||
<div className={cn("w-full", fill.outer)}>
|
||||
<div className={cn("overflow-hidden rounded-lg border border-border", fill.frame)}>
|
||||
{toolbar !== undefined && <div className="shrink-0 border-b border-border px-4 py-3">{toolbar(table)}</div>}
|
||||
<div
|
||||
className={stickyHeader ? "overflow-auto" : "overflow-x-auto"}
|
||||
style={stickyHeader ? { maxHeight: maxBodyHeight } : undefined}
|
||||
className={cn(stickyHeader ? "overflow-auto" : "overflow-x-auto", fill.body)}
|
||||
style={maxBodyHeight !== undefined ? { maxHeight: maxBodyHeight } : undefined}
|
||||
>
|
||||
<TableRoot className={enableColumnResizing ? "table-fixed" : ""} style={tableStyle}>
|
||||
<TableHeader className={stickyHeader ? "sticky top-0 z-20" : ""}>
|
||||
<TableHeader className={cn(stickyHeader ? "sticky top-0 z-20" : "", fill.header)}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
|
||||
{headerGroup.headers.map((header) => (
|
||||
|
|
@ -631,7 +649,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
|
|||
{footer !== undefined && <TableFooter>{footer(table)}</TableFooter>}
|
||||
</TableRoot>
|
||||
</div>
|
||||
{paginationNode !== null && <div className="border-t border-border">{paginationNode}</div>}
|
||||
{paginationNode !== null && <div className="shrink-0 border-t border-border">{paginationNode}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@ export interface DataTableProps<TData extends RowData, TValue> {
|
|||
rowClassName?: (row: Row<TData>) => string;
|
||||
|
||||
maxBodyHeight?: number | string;
|
||||
/**
|
||||
* Scroll the rows inside whatever height the parent gives the table, rather than growing the page.
|
||||
* The table becomes a flex column, so the parent must be a height-constrained flex container; without
|
||||
* one it degrades to the normal auto-height layout. Use instead of `maxBodyHeight` to avoid a magic number.
|
||||
*/
|
||||
fillHeight?: boolean;
|
||||
size?: DataTableSize;
|
||||
|
||||
toolbar?: (table: Table<TData>) => React.ReactNode;
|
||||
|
|
|
|||
|
|
@ -403,6 +403,41 @@ describe("RequestLogsPanel", () => {
|
|||
expect(drawer()).toHaveAttribute("data-session-id", "sess-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking a multi-call session's row writes ?session_id= alongside ?log_id=", async () => {
|
||||
const user = userEvent.setup();
|
||||
respondWith([
|
||||
logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
|
||||
logEntry({ request_id: "req-llm-2", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
|
||||
]);
|
||||
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
|
||||
|
||||
await waitFor(() => expect(row("req-llm")).not.toBeNull());
|
||||
await user.click(row("req-llm") as HTMLElement);
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
expect(params.get("session_id")).toBe("sess-1");
|
||||
expect(params.get("log_id")).toBe("req-llm");
|
||||
await waitFor(() => expect(drawer()).toHaveAttribute("data-session-id", "sess-1"));
|
||||
});
|
||||
|
||||
it("selecting another log while a session view is open keeps the session open", async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.replaceState(null, "", "/logs/?log_id=req-llm");
|
||||
respondWith([
|
||||
logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
|
||||
logEntry({ request_id: "req-unenriched" }),
|
||||
]);
|
||||
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
|
||||
|
||||
await waitFor(() => expect(drawer()).toHaveAttribute("data-session-id", "sess-1"));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "select-next-log" }));
|
||||
|
||||
await waitFor(() => expect(drawer()).toHaveAttribute("data-log-id", "req-unenriched"));
|
||||
expect(new URLSearchParams(window.location.search).get("session_id")).toBe("sess-1");
|
||||
expect(drawer()).toHaveAttribute("data-session-id", "sess-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("live tail", () => {
|
||||
|
|
|
|||
|
|
@ -235,9 +235,13 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
const handleRowClick = useCallback(
|
||||
(log: LogEntry) => {
|
||||
setSelectedLog(log);
|
||||
openLog(log.request_id);
|
||||
if (log.session_id && (log.session_total_count || 1) > 1) {
|
||||
openSession(log.session_id, log.request_id);
|
||||
} else {
|
||||
openLog(log.request_id);
|
||||
}
|
||||
},
|
||||
[openLog],
|
||||
[openLog, openSession],
|
||||
);
|
||||
|
||||
const handleSessionClick = useCallback(
|
||||
|
|
@ -253,9 +257,9 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
const handleSelectLog = useCallback(
|
||||
(log: LogEntry) => {
|
||||
setSelectedLog(log);
|
||||
selectLog(log.request_id);
|
||||
selectLog(log.request_id, displaySessionId);
|
||||
},
|
||||
[selectLog],
|
||||
[selectLog, displaySessionId],
|
||||
);
|
||||
|
||||
const handleKeyHashClick = useCallback((keyHash: string) => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export interface LogDetailRouting {
|
|||
sessionId: string | null;
|
||||
openLog: (requestId: string) => void;
|
||||
openSession: (sessionId: string, requestId: string | null) => void;
|
||||
selectLog: (requestId: string) => void;
|
||||
selectLog: (requestId: string, sessionId?: string | null) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -36,9 +36,12 @@ export function useLogDetailRouting(): LogDetailRouting {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const selectLog = useCallback((requestId: string) => {
|
||||
const selectLog = useCallback((requestId: string, sessionId?: string | null) => {
|
||||
navigateWithParams((params) => {
|
||||
params.set(LOG_ID_QUERY_PARAM, requestId);
|
||||
if (sessionId) {
|
||||
params.set(SESSION_ID_QUERY_PARAM, sessionId);
|
||||
}
|
||||
}, "replace");
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
246
uv.lock
generated
246
uv.lock
generated
|
|
@ -10,7 +10,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-07-27T18:40:42.08538Z"
|
||||
exclude-newer = "2026-07-28T06:59:32.050819Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -20,7 +20,7 @@ members = [
|
|||
"litellm-proxy-extras",
|
||||
]
|
||||
constraints = [
|
||||
{ name = "aiohttp", specifier = ">=3.14.1,<4.0" },
|
||||
{ name = "aiohttp", specifier = ">=3.14.2,<4.0" },
|
||||
{ name = "httplib2", specifier = ">=0.32.0" },
|
||||
{ name = "packaging", specifier = ">=24.0" },
|
||||
{ name = "setuptools", specifier = ">=83.0.0" },
|
||||
|
|
@ -82,7 +82,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.14.1"
|
||||
version = "3.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
|
|
@ -95,126 +95,126 @@ dependencies = [
|
|||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/4d/4a99fb425c5e0cad715eea7bd190aff46f38b959a0a2dadb993705d34b26/aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b", size = 765848, upload-time = "2026-07-23T01:52:08.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/e8/43b85dc55b8e950dc644babe762add781319ea881b57b33d2cce12017d12/aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a", size = 517476, upload-time = "2026-07-23T01:52:10.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9e/73b582c4dbbc3c12ef4473822475effaabf1f934b56f14f5b03fe5d3a2af/aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5", size = 515334, upload-time = "2026-07-23T01:52:12.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/03/e98c3c9e05a5bdf97defe5ff9169baba4f0ec9a901f2d60e0f060c2f051e/aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f", size = 1708830, upload-time = "2026-07-23T01:52:14.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/2c/26e60b694844dfd2176c57f913a22d0cd6a16f9ff202cbda7580d0328b98/aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43", size = 1674012, upload-time = "2026-07-23T01:52:16.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/65/672df92e3172cd876aacfa97a952ac560877eb169384b2991ac5b273de4c/aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9", size = 1767015, upload-time = "2026-07-23T01:52:18.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c5/228dec7bfec1c373cc2217cdeb47d6456dcd7a13a4c55144930a75ae3851/aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8", size = 1858700, upload-time = "2026-07-23T01:52:20.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/ff/cb36724e8c8d17f90ada567a9ff3efe1d6e9b549fba697a242aece180f21/aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479", size = 1714075, upload-time = "2026-07-23T01:52:22.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3a/296a4135c6366376263aeef54b15caca1f07676c2ae0c525d7832f2f808a/aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b", size = 1588234, upload-time = "2026-07-23T01:52:23.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/81/9d5d853ef892dc066d1eb6db0e87a47348b920c1c879aa554612fdbd9d79/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d", size = 1677300, upload-time = "2026-07-23T01:52:25.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/96/021d386ae32d9b26d4b88df2e794546232ff56bb6be952bf6be227c0bbc7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d", size = 1691501, upload-time = "2026-07-23T01:52:28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9f/af66adce26a14af135c003cbd0f44ccaa68cebd30ff8ac99ca47fb4958f7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2", size = 1735113, upload-time = "2026-07-23T01:52:29.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/90/28c390d4c9851effe52ac25b5a2e1d92246acd00728b4fc7975dafb67484/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48", size = 1577486, upload-time = "2026-07-23T01:52:31.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/c2/00e23a1bf2abb70dd353f6987db7e7f2491d0261f7363997738c71c98f95/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f", size = 1751353, upload-time = "2026-07-23T01:52:33.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/7d/d51a706a8cbfa57f0611127daf61ab3ae02ab8420b0407412079227d1c65/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32", size = 1698681, upload-time = "2026-07-23T01:52:38.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/b0/90bd5cd9fdd9787cb4211d284d1fb8401339a933cb0227a15b71e789232f/aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e", size = 456733, upload-time = "2026-07-23T01:52:41.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/15/fe5b8f6a71ae112bc677163d0b0701bda5dc15005249582258ede0eb88c7/aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c", size = 480460, upload-time = "2026-07-23T01:52:43.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/00/45e98b6645cd7f00a4b78b749ebd309094b0eaeb2d2e96157eadbc0d0050/aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb", size = 453479, upload-time = "2026-07-23T01:52:46.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4327,7 +4327,7 @@ proxy-dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "a2a-sdk", marker = "extra == 'extra-proxy'", specifier = ">=1.1.0,<2.0" },
|
||||
{ name = "aiohttp", specifier = ">=3.10,<4.0" },
|
||||
{ name = "aiohttp", specifier = ">=3.14.2,<4.0" },
|
||||
{ name = "anthropic", extras = ["vertex"], marker = "extra == 'proxy-runtime'", specifier = ">=0.84.0,<1.0" },
|
||||
{ name = "apscheduler", marker = "extra == 'proxy'", specifier = ">=3.11.2,<4.0" },
|
||||
{ name = "audioread", marker = "extra == 'stt-nvidia-riva'", specifier = ">=3.0.1" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue