Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/mock-testing-feature-flag-6e30ad

This commit is contained in:
Yuneng Jiang 2026-07-31 17:15:15 -07:00
commit 2a9aa966ce
No known key found for this signature in database
30 changed files with 2007 additions and 494 deletions

View file

@ -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 = []

View file

@ -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)

View file

@ -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",

View file

@ -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:

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -3,7 +3,7 @@
"limit": 23253
},
"LIT002": {
"limit": 27427
"limit": 27280
},
"LIT003": {
"limit": 292

View file

@ -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

View file

@ -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();
});
});

View file

@ -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>
</>
)}
/>
);
};

View file

@ -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
? [
{

View file

@ -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));
});
});

View file

@ -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">

View file

@ -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({});
});
});

View file

@ -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));

View file

@ -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 = () => {

View file

@ -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([]);
});
});

View file

@ -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,
};
}

View file

@ -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" };

View file

@ -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

View file

@ -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>

View file

@ -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", () => {

View file

@ -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>
);

View file

@ -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;

View file

@ -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", () => {

View file

@ -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) => {

View file

@ -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
View file

@ -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" },