Merge remote-tracking branch 'origin/main' into codex/pr-1449

This commit is contained in:
debpalash
2026-08-10 01:08:02 +00:00
42 changed files with 1696 additions and 250 deletions
+5
View File
@@ -41,9 +41,14 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
### Fixed
- Custom MLX model IDs and saved voice instructions are now validated in bounded time, so malformed input cannot stall the backend. (#1446)
- Streaming and provider failures now return stable recovery guidance without exposing exception details. (#1462)
- Server-mode settings mutations require the admin API key, while host destinations and executable paths can only be selected through the native desktop app. (#1448)
- Automatic model-mirror checks now reject untrusted URLs before opening a network connection. (#1447)
- Sidecar engines no longer break when a library they load prints to the console. Those bytes landed in the middle of the engine's data stream, failing the generation and leaving the connection scrambled for every request after it. (#1428) — thanks @1335-Group!
- A generation abandoned while stuck on an internal lock now says so, instead of blaming your hardware and suggesting shorter text. Nothing had been computed, so none of that advice applied. (#1416, #1419)
- A machine with a GPU that ends up on CPU now says why — a missing device node, a permissions problem, a card newer than the installed ROCm, an `HSA_OVERRIDE_GFX_VERSION` that is doing more harm than good, or an NVIDIA driver the container can't reach each read differently. Before, all of them looked identical to having no GPU at all. (#1274, #1228)
- The first generation on an engine that still has to install itself no longer gives up part-way. The install reports progress now, so the generation waits for it instead of hitting its own five-minute limit. (#1414)
- A slow machine is no longer told its IndexTTS-2 install isn't there. The check that confirms an engine's virtualenv gave up after 10 seconds and counted that as a broken install, so a cold first run 500'd; it now waits longer and treats slow as unproven, not broken. (#1414) — thanks @OracleNightmare!
- A broken Python environment now says so, instead of blaming the app's own install. A missing or mismatched torch/transformers surfaced as "omnivoice not importable" and sent people reinstalling the wrong thing. (#1415)
- A model that fails to load at startup no longer leaves the app looking healthy while producing nothing — the failure and its remedy now show up in the model status. (#1415)
+39
View File
@@ -186,6 +186,45 @@ def require_loopback(request: Request) -> None:
raise HTTPException(status_code=403, detail="loopback origin required")
def require_admin(request: Request) -> None:
"""Gate RCE/filesystem-capable admin routers.
Desktop callers keep the loopback-only contract. Docker cannot reliably
observe the host operator as loopback, so authenticated remote admin stays
available there, but every state-changing request must present the long API
key. An unconfigured server must never expose executable-path or filesystem
settings to every client that can reach its published port.
Read-only requests retain the bare-Docker bootstrap behaviour until an API
key is configured. Share PINs and trusted CIDRs are consumption credentials;
neither authorizes this gate.
"""
host = request.client.host if request.client else None
if is_loopback(host):
return
if _server_mode():
method = str(getattr(request, "method", "GET")).upper()
read_only = method in {"GET", "HEAD", "OPTIONS"}
if read_only and not os.environ.get("OMNIVOICE_API_KEY", "").strip():
return
if _request_presents_admin_credential(request):
return
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
def require_desktop(request: Request) -> None:
"""Gate capabilities that may select or execute host filesystem paths.
An API key authorizes remote administration, not access to the desktop
shell's native file-picker boundary. These capabilities therefore remain
strictly loopback-only even when server mode is enabled.
"""
host = request.client.host if request.client else None
if is_loopback(host):
return
raise HTTPException(status_code=403, detail="desktop origin required")
def require_local(request: Request) -> None:
"""Reject any request whose client.host is not loopback OR on a configured
trusted network. The consumption-tier companion to :func:`require_loopback`:
+28 -14
View File
@@ -734,7 +734,7 @@ async def dub_transcribe_stream(
preflight_error = asr_model_missing_detail(e.payload)
preflight_payload = e.payload
except Exception as e:
logger.exception("transcribe preflight: ASR load failed (job=%r)", job_id)
logger.error("Transcription preflight ASR load failed")
from core.failure import build_failure
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
preflight_error = "ASR backend initialization failed: " + f["reason"] + (
@@ -765,9 +765,10 @@ async def dub_transcribe_stream(
try:
audio_np, sr = await loop.run_in_executor(_cpu_pool, _load)
except Exception as e:
except Exception:
# Terminal error → always emit `done` (see preflight note, #578).
yield _sse_event("error", {"detail": f"audio load failed: {e}", "retryable": True})
from core.public_errors import stream_failure
yield _sse_event("error", stream_failure("transcription_failed"))
yield _sse_event("done", {})
return
@@ -867,9 +868,16 @@ async def dub_transcribe_stream(
continue
turns.append({"start": s0 + offset, "end": s1 + offset, "speaker": spk})
return {"chunks": shifted, "language": r.get("language"), "speaker_turns": turns}
except Exception as e:
logger.exception("chunk transcribe failed (backend=%s)", _asr_backend.id)
return {"chunks": [], "language": None, "error": str(e)}
except Exception:
logger.error("Chunk transcription failed (backend=%s)", _asr_backend.id)
from core.public_errors import stream_failure
failure = stream_failure("transcription_failed")
return {
"chunks": [],
"language": None,
"error": failure["detail"],
"error_code": failure["code"],
}
# Retry a failed/timed-out chunk once on a fresh pool before giving
# up. Otherwise a transient wedge on the FIRST chunk (whisperx often
@@ -900,7 +908,7 @@ async def dub_transcribe_stream(
yield _sse_event("ping", {})
try:
part = task.result()
except ASRTimeoutError as e:
except ASRTimeoutError:
# The guard already reset the pool; keep the actionable
# message (it names the durable fixes, and — after repeated
# timeouts — the crash-isolated engine escape hatch).
@@ -910,7 +918,14 @@ async def dub_transcribe_stream(
i + 1, chunks_n, transcribe_timeout_s, _attempt,
_CHUNK_TRANSCRIBE_ATTEMPTS, job_id,
)
part = {"chunks": [], "language": None, "error": str(e)}
from core.public_errors import stream_failure
failure = stream_failure("transcription_timeout")
part = {
"chunks": [],
"language": None,
"error": failure["detail"],
"error_code": failure["code"],
}
# Success → keep it. Failure/timeout → retry once on a fresh
# worker (the internal _transcribe_chunk except returns an
# error-part; the timeout path already reset the pool).
@@ -964,6 +979,7 @@ async def dub_transcribe_stream(
"segments": chunk_segs,
"progress": (i + 1) / chunks_n,
"error": part.get("error"),
"error_code": part.get("error_code"),
})
if job.get("aborted"):
@@ -1447,12 +1463,10 @@ async def dub_transcribe_stream(
try:
async for ev in _gen_body():
yield ev
except Exception as e: # noqa: BLE001 — last-resort stream finalizer
logger.exception("transcribe stream crashed (job=%r)", job_id)
from core.failure import build_failure
f = build_failure(e, stage="transcribe", include_diagnostic=False)
detail = f["reason"] + (f"{f['hint']}" if f.get("hint") else "")
yield _sse_event("error", {"detail": detail, "retryable": True})
except Exception: # noqa: BLE001 — last-resort stream finalizer
logger.error("Transcription stream failed unexpectedly")
from core.public_errors import stream_failure
yield _sse_event("error", stream_failure("transcription_failed"))
yield _sse_event("done", {})
finally:
# Last-resort VRAM release (see _loaded_asr above): covers crashes,
+15 -4
View File
@@ -16,11 +16,12 @@ Environment variables (`OMNIVOICE_TTS_BACKEND`, `OMNIVOICE_ASR_BACKEND`,
a backend without Settings silently undoing it.
"""
import os
import re
import threading
from time import perf_counter
from fastapi import APIRouter, Depends, HTTPException
from huggingface_hub import utils as hf_utils
from huggingface_hub.errors import HFValidationError
from pydantic import BaseModel
from api.dependencies import require_loopback
@@ -37,6 +38,16 @@ _FAMILIES = {
"llm": (llm_backend, "llm_backend"),
}
def _is_hf_repo_id(value: str) -> bool:
"""Validate the route's ``owner/repo`` contract in bounded time."""
if not isinstance(value, str) or len(value) > 96 or value.count("/") != 1:
return False
try:
hf_utils.validate_repo_id(value)
except (HFValidationError, TypeError):
return False
return True
@router.get("/engines")
def list_all_engines():
@@ -579,11 +590,11 @@ def select_engine(req: SelectEngineRequest):
# Anything else (typo'd key, malformed id) is rejected outright
# rather than silently persisted as a "custom repo" that then fails
# to resolve at load time.
if req.model_id not in known_keys and not re.fullmatch(r"[\w.-]+/[\w.-]+", req.model_id):
if req.model_id not in known_keys and not _is_hf_repo_id(req.model_id):
raise HTTPException(
400,
f"Unknown mlx-audio model: {req.model_id!r}. Expected one of "
f"{sorted(known_keys)} or a HF repo id like 'owner/name'.",
"Unknown mlx-audio model. Expected a curated model key or a "
"Hugging Face repo ID like 'owner/name'.",
)
prefs.set_("mlx_audio_model_id", req.model_id)
prefs.set_(pref_key, req.backend_id)
+13 -11
View File
@@ -1538,17 +1538,19 @@ async def generate_speech(
# In-band error frame carries the machine-readable retryable
# marker (#1190) — an NDJSON consumer can back off instead of
# guessing from the prose.
logger.error("Streaming generate timed out: %s", e)
yield _line({
"type": "error", "detail": str(e), "retryable": True,
"retry_after": getattr(e, "retry_after", 30),
})
except ValueError as e:
logger.error("Streaming generate validation failed: %s", e)
yield _line({"type": "error", "detail": str(e)})
except Exception as e:
logger.error("Streaming generate failed: %s\n%s", e, traceback.format_exc())
yield _line({"type": "error", "detail": _safe_exc_text(e)})
logger.error("Streaming generation capacity unavailable")
from core.public_errors import stream_failure
failure = stream_failure("generation_busy")
failure["retry_after"] = getattr(e, "retry_after", 30)
yield _line({"type": "error", **failure})
except ValueError:
logger.error("Streaming generation request rejected")
from core.public_errors import stream_failure
yield _line({"type": "error", **stream_failure("invalid_request")})
except Exception:
logger.error("Streaming generation failed unexpectedly")
from core.public_errors import stream_failure
yield _line({"type": "error", **stream_failure("generation_failed")})
finally:
# Ownership of the temp reference clip moves to this generator
# in stream mode (the route returns before rendering starts).
+7 -2
View File
@@ -19,7 +19,7 @@ router = APIRouter(dependencies=[Depends(require_loopback)])
class CustomPathRequest(BaseModel):
path: str
authorization: str
def _svc():
@@ -61,8 +61,13 @@ def media_tools_ytdlp_restore():
@router.post("/media-tools/{tool}/custom-path")
def media_tools_custom_path(tool: str, body: CustomPathRequest):
from core.path_authorization import PathAuthorizationError, consume
try:
return _svc().set_custom_path(tool, body.path)
path = consume(body.authorization, tool)
return _svc().set_custom_path(tool, path)
except PathAuthorizationError as e:
raise HTTPException(status_code=403, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
+24 -46
View File
@@ -1,11 +1,10 @@
"""Settings API — HF token save/clear/state endpoints (Phase 1 AUTH-03 backend half).
These endpoints are the backend half of the Wave 2 Settings API Keys
panel. Threat T-01-03 mitigation: every write endpoint is gated by the
router-level `require_loopback` dep, so non-loopback origins get 403
before the handler runs. Reads are loopback-gated too the masked
token preview is useful telemetry that we still don't want exposed on
the LAN.
panel. Threat T-01-03 mitigation: the router-level `require_admin` dependency
keeps desktop callers loopback-only and requires the long API key for every
remote server-mode mutation. Read-only bare-Docker discovery remains available
until an API key is configured; once configured, reads require it too.
The state endpoint duplicates `/system/hf-token/state` (which lives on
`system.py` for legacy-router compatibility); both return the same shape.
@@ -20,14 +19,14 @@ from dataclasses import asdict
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from api.dependencies import require_loopback
from api.dependencies import require_admin
logger = logging.getLogger("omnivoice.api.settings")
router = APIRouter(
prefix="/api/settings",
tags=["settings"],
dependencies=[Depends(require_loopback)],
dependencies=[Depends(require_admin)],
)
@@ -445,42 +444,20 @@ def test_llm_provider(provider_id: str):
"reply": reply[:80],
"latency_ms": int((_time.monotonic() - t0) * 1000),
}
except Exception as e: # noqa: BLE001 — surface a clean, scrubbed error to the UI
except Exception as e: # noqa: BLE001 — classify without exposing diagnostics
kind = _classify_llm_error(e)
detail = _scrub_llm_detail(e, api_key)
# A 404 from a LOCAL server is almost never a wrong URL — the request
# reached it — it is a model name the server does not have loaded. The
# generic "check the model name and Base URL path" sends the user to
# audit a URL that works. Ask what IS loaded and say so (#1332).
from core.public_errors import provider_failure
failure = provider_failure(kind)
# A successful local catalog probe proves the cached model is stale.
# Invalidate it, but never include catalog or exception text in the
# response: both are controlled by the provider.
if kind == "not_found" and p.local:
available = _local_models(base_url, api_key)
asked = llm_providers.resolve_model(p)
if available:
detail = (
f"{p.display_name} is running, but has no model named "
f"{asked!r}. Loaded right now: {', '.join(available[:10])}"
+ ("" if len(available) > 10 else "")
+ ". Pick one in the Model field above."
)
# The cached discovery, if any, produced a name this server
# rejects — most likely the user swapped the loaded model
# inside the local app. Drop it so the next attempt re-asks
# rather than repeating the same 404 until the TTL expires.
if available is not None:
llm_providers.forget_discovered_models(p.id)
elif available == []:
detail = (
f"{p.display_name} is running, but reports no loaded models, "
f"so {asked!r} cannot be served. Load a model in "
f"{p.display_name} first, then test again."
)
llm_providers.forget_discovered_models(p.id)
# available is None: the model listing itself failed, so nothing
# here is established. Keep the generic 404 text rather than
# inventing a diagnosis the lookup did not support.
return {
"ok": False,
"kind": kind,
"detail": detail,
**failure,
"latency_ms": int((_time.monotonic() - t0) * 1000),
}
@@ -529,10 +506,10 @@ def list_llm_provider_models(provider_id: str):
# can say "first 200 shown" rather than implying it's the full list.
return {"ok": True, "models": ids[:200], "truncated": len(ids) > 200}
except Exception as e: # noqa: BLE001
from core.public_errors import provider_failure
return {
"ok": False,
"kind": _classify_llm_error(e),
"detail": _scrub_llm_detail(e, api_key),
**provider_failure(_classify_llm_error(e)),
"models": [],
}
@@ -685,7 +662,7 @@ def _effective_models_dir() -> str:
class _ModelsDirBody(BaseModel):
path: str = Field(default="", description="Absolute directory; empty clears → default cache")
authorization: str = Field(description="One-shot native desktop authorization")
@router.get("/storage/models-dir")
@@ -714,17 +691,18 @@ def set_models_dir(body: _ModelsDirBody):
saved. Returns restart_required=True.
"""
from core import user_env
from core.path_authorization import PathAuthorizationError, consume
raw = (body.path or "").strip()
try:
raw = consume(body.authorization, "models_dir").strip()
except PathAuthorizationError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
if not raw:
user_env.unset_user_env(_MODELS_DIR_ENV)
return {"configured": None, "default": _default_models_dir(), "restart_required": True}
# Reject control characters / NUL before touching the filesystem: an
# embedded NUL makes os.makedirs raise ValueError (→ 500). This is also
# the input-validation barrier for the path before it reaches any fs call
# (the dir is user-chosen by design — this is a loopback-gated, same-user
# local file picker, not a cross-privilege boundary).
# Tauri already validates this before issuing the capability. Keep the
# backend checks as defense in depth against a corrupt capability file.
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in raw):
raise HTTPException(status_code=400, detail="Path contains invalid control characters")
+18 -28
View File
@@ -11,7 +11,7 @@ from core.prefs import set_ as prefs_set, delete as prefs_delete
from services import network_share
from services import tailscale as _tailscale
from api.schemas import SysinfoResponse, SystemInfoResponse, ModelStatusResponse
from api.dependencies import require_loopback
from api.dependencies import is_loopback, require_admin
from fastapi.responses import FileResponse, StreamingResponse
import torch
import shutil
@@ -21,17 +21,16 @@ from core.version import APP_VERSION
from services.model_manager import get_model_status, get_best_device, resolve_omnivoice_checkpoint
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
# Router-level loopback gate. Every route mounted on `router` (GET + POST,
# present and future) is gated by `require_loopback`, which 403s any request
# whose `client.host` is not a loopback address. This closes the same trust
# Router-level admin gate. Every route mounted on `router` (GET + POST,
# present and future) is gated by `require_admin`: desktop requests must be
# loopback; server-mode mutations require the long API key. This closes the trust
# boundary that PR #81 only patched on `/system/set-env` and that the
# 260518-ivy deferred-items file enumerated for follow-up: /model/unload/*,
# /system/logs/clear, /system/logs/tauri/clear, /system/flush-memory,
# /clean-audio (POSTs) plus the read-side info-disclosure routes
# /system/info, /system/logs, /system/logs/tauri, /system/logs/stream.
# This router only ever serves the local Tauri shell and the dev frontend
# at http://127.0.0.1:3901 — both are loopback origins.
router = APIRouter(dependencies=[Depends(require_loopback)])
# Native Tauri/dev callers remain loopback and need no credential.
router = APIRouter(dependencies=[Depends(require_admin)])
logger = logging.getLogger("omnivoice.api")
# Cache device checks at module load — they don't change at runtime
@@ -806,7 +805,6 @@ async def ack_crash():
PERSISTENT_KEYS = {
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
"http_proxy", "https_proxy", "all_proxy",
"FFMPEG_PATH", "FFPROBE_PATH",
"TRANSLATE_BASE_URL", "TRANSLATE_API_KEY", "TRANSLATE_MODEL",
"DEEPL_API_KEY", "DEEPL_BASE_URL",
"MICROSOFT_API_KEY", "MICROSOFT_BASE_URL",
@@ -843,7 +841,7 @@ async def set_env_var(body: dict):
are set on ``os.environ`` for the running process.
The loopback-origin gate that previously lived inline here is now applied
at the router level via `dependencies=[Depends(require_loopback)]` on
at the router level via `dependencies=[Depends(require_admin)]` on
`router` see the top of this file. Every route on this router is
gated, including this one. The 403 body and behavior are unchanged.
"""
@@ -858,23 +856,6 @@ async def set_env_var(body: dict):
)
if value:
# Validate executable paths if the user is setting them manually.
# Reject control characters / null bytes (defense-in-depth against
# path-injection), then require an existing regular file. NOTE: this
# endpoint is loopback-only and MUST remain so — a remote caller able
# to set FFMPEG_PATH/FFPROBE_PATH could point it at an arbitrary
# binary (RCE). Network sharing must never expose /system/set-env.
if key in ("FFMPEG_PATH", "FFPROBE_PATH"):
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in value):
raise HTTPException(
status_code=400,
detail="Invalid path: control characters are not allowed",
)
if not os.path.isfile(value):
raise HTTPException(
status_code=400,
detail=f"File not found: {value}",
)
# Port keys must be a numeric string in the unprivileged range so a
# typo can't drop the backend onto a privileged port (<1024) or an
# out-of-range value uvicorn would reject at bind time.
@@ -1101,12 +1082,21 @@ def quarantine_status():
# ── Network sharing (loopback-only control surface) ──────────────────────────
@router.get("/system/network/state")
async def network_state():
async def network_state(request: Request):
st = network_share.get_state()
# PIN-only server mode permits unauthenticated read-only discovery, but the
# PIN is itself a consumption credential. Reveal it only to the native
# loopback UI or to a remote caller that already passed the configured
# long API-key gate. The boolean lets headless dashboards remain useful.
host = request.client.host if request.client else None
may_reveal_pin = is_loopback(host) or bool(
os.environ.get("OMNIVOICE_API_KEY", "").strip()
)
return {
"enabled": st.enabled,
"share_port": st.share_port,
"pin": st.pin,
"pin": st.pin if may_reveal_pin else None,
"pin_required": bool(st.pin),
"lan_addresses": st.lan_addresses,
}
+88
View File
@@ -0,0 +1,88 @@
"""Consume one-shot host paths authorized by the native Tauri process.
The web API never accepts a filesystem destination or executable path. Tauri
validates the user's native IPC request, writes a private capability file, and
only the unguessable capability token crosses loopback HTTP.
"""
from __future__ import annotations
import json
import os
import re
import secrets
import stat
from core.config import DATA_DIR
_TOKEN_RE = re.compile(r"[0-9a-f]{64}\Z")
_KINDS = {"models_dir", "ffmpeg", "ffprobe"}
_AUTH_DIR = os.path.join(DATA_DIR, ".path-authorizations")
class PathAuthorizationError(ValueError):
pass
def consume(token: str, expected_kind: str) -> str:
"""Consume and return a single Tauri-authorized path.
Capability files are one-shot and opened without following symlinks. Tauri
writes them into the app's private data directory; source/Docker callers
cannot mint a valid token through HTTP.
"""
if expected_kind not in _KINDS or not _TOKEN_RE.fullmatch(token or ""):
raise PathAuthorizationError("Invalid or expired desktop authorization")
root = _AUTH_DIR
candidate = None
try:
for entry in os.scandir(root):
if not _TOKEN_RE.fullmatch(entry.name.removesuffix(".json")):
continue
if not entry.is_file(follow_symlinks=False):
continue
try:
with open(entry.path, "r", encoding="utf-8") as handle:
probe = json.load(handle)
except (OSError, UnicodeError, json.JSONDecodeError):
continue # Ignore corrupt/stale capabilities; they authorize nothing.
if isinstance(probe, dict) and secrets.compare_digest(
str(probe.get("token", "")), token
):
candidate = entry.path
break
if candidate is None:
raise OSError("capability not found")
claimed = os.path.join(root, f".consuming-{os.getpid()}-{secrets.token_hex(16)}")
os.replace(candidate, claimed)
except OSError as exc:
raise PathAuthorizationError("Invalid or expired desktop authorization") from exc
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
fd = os.open(claimed, flags)
except OSError as exc:
raise PathAuthorizationError("Invalid or expired desktop authorization") from exc
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_size > 16_384:
raise PathAuthorizationError("Invalid desktop authorization")
with os.fdopen(fd, "r", encoding="utf-8") as handle:
fd = -1
payload = json.load(handle)
except (OSError, UnicodeError, json.JSONDecodeError, TypeError) as exc:
raise PathAuthorizationError("Invalid desktop authorization") from exc
finally:
if fd >= 0:
os.close(fd)
try:
os.unlink(claimed)
except OSError:
pass # Best-effort cleanup; the random claimed name cannot be reused.
if not isinstance(payload, dict):
raise PathAuthorizationError("Invalid desktop authorization")
if not secrets.compare_digest(str(payload.get("token", "")), token):
raise PathAuthorizationError("Invalid desktop authorization")
if payload.get("kind") != expected_kind or not isinstance(payload.get("path"), str):
raise PathAuthorizationError("Desktop authorization does not match this setting")
return payload["path"]
+53
View File
@@ -0,0 +1,53 @@
"""Data-independent error metadata safe for API and streaming responses."""
from __future__ import annotations
_PROVIDER_DETAILS = {
"auth": "Authentication failed. Check the provider API key.",
"not_found": "Provider or model not found. Check the model and Base URL.",
"rate_limit": "The provider rate limit was reached. Try again later.",
"network": "The provider could not be reached. Check the connection and Base URL.",
"config": "Configure the provider before using it.",
"error": "The provider request failed. Try again.",
}
def provider_failure(kind: str) -> dict[str, str]:
"""Return a stable provider error class and remediation message."""
safe_kind = kind if kind in _PROVIDER_DETAILS else "error"
return {"kind": safe_kind, "detail": _PROVIDER_DETAILS[safe_kind]}
def stream_failure(code: str) -> dict[str, object]:
"""Return stable stream metadata selected only from an internal code."""
failures: dict[str, dict[str, object]] = {
"generation_busy": {
"code": "generation_busy",
"detail": "Generation capacity is busy. Try again shortly.",
"retryable": True,
},
"invalid_request": {
"code": "invalid_request",
"detail": "The generation request could not be processed.",
"retryable": False,
},
"generation_failed": {
"code": "generation_failed",
"detail": "Generation failed. Check the selected engine and try again.",
"retryable": True,
},
"transcription_failed": {
"code": "transcription_failed",
"detail": "Transcription failed. Check the selected ASR engine and try again.",
"retryable": True,
},
"transcription_timeout": {
"code": "transcription_timeout",
"detail": (
"Transcription timed out while the backend is running. Increase "
"OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S or select the "
"faster-whisper-isolated engine, then try again."
),
"retryable": True,
},
}
return dict(failures.get(code, failures["generation_failed"]))
+3 -3
View File
@@ -160,10 +160,10 @@ def parse(text: str) -> Direction:
try:
body = llm.chat(system=_LLM_PROMPT, user=text)
except Exception as e:
logger.warning("director LLM parse failed: %s", e)
except Exception:
logger.warning("director LLM parse failed; using heuristic parser")
d = _heuristic_parse(text)
d.error = f"llm-parse-failed: {e}"
d.error = "llm-parse-failed"
return d
raw = body.strip()
+26 -2
View File
@@ -83,6 +83,26 @@ _race_lock = threading.Lock()
_FAILOVER_ATTEMPTED: set[str] = set()
def _is_allowed_probe_endpoint(endpoint: str) -> bool:
"""Only probe the two fixed HTTPS origins shipped by VoiceStudio."""
try:
parsed = urlsplit(endpoint)
port = parsed.port
except (TypeError, ValueError):
return False
return (
parsed.scheme == "https"
and parsed.hostname in {urlsplit(CANONICAL_ENDPOINT).hostname,
urlsplit(COMMUNITY_MIRROR).hostname}
and port in (None, 443)
and parsed.username is None
and parsed.password is None
and parsed.path in ("", "/")
and not parsed.query
and not parsed.fragment
)
@dataclass
class ProbeResult:
endpoint: str
@@ -120,11 +140,13 @@ def probe_endpoint(endpoint: str, timeout: float = PROBE_TIMEOUT_S) -> ProbeResu
Any HTTP response (even an error status) counts as reachable the probe
measures whether the network path works, not whether a specific resource
exists. Never raises."""
if not _is_allowed_probe_endpoint(endpoint):
return ProbeResult(endpoint=endpoint, reachable=False, error="invalid_endpoint")
url = endpoint.rstrip("/") + "/"
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "VoiceStudio-endpoint-probe"})
start = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=timeout):
with urllib.request.urlopen(req, timeout=timeout): # nosec B310 -- fixed HTTPS allowlist above
pass
except urllib.error.HTTPError:
pass # the server answered → reachable
@@ -143,6 +165,8 @@ def throughput_probe(endpoint: str, timeout: float = PROBE_TIMEOUT_S) -> Optiona
Used only as a tiebreak confirmation when latency says the mirror is
decisively faster throughput is what a multi-GB download actually
feels. Best-effort; any failure returns None (tiebreak skipped)."""
if not _is_allowed_probe_endpoint(endpoint):
return None
url = endpoint.rstrip("/") + _THROUGHPUT_SAMPLE_PATH
req = urllib.request.Request(
url,
@@ -155,7 +179,7 @@ def throughput_probe(endpoint: str, timeout: float = PROBE_TIMEOUT_S) -> Optiona
total = 0
start = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 -- fixed HTTPS allowlist above
while total < _THROUGHPUT_SAMPLE_BYTES and time.monotonic() < deadline:
chunk = resp.read(min(65536, _THROUGHPUT_SAMPLE_BYTES - total))
if not chunk:
+6 -3
View File
@@ -184,9 +184,12 @@ def adjust_for_slot(
system=system, user="\n".join(user_lines),
temperature=0.2, # pinned like the Fast path — default 1.0 drifts/invents
)
except Exception as e:
logger.warning("speech-rate attempt %d failed: %s", attempt, e)
return {"text": best[0], "rate_ratio": best[1], "attempts": attempt - 1, "error": str(e)}
except Exception:
logger.warning("speech-rate provider attempt %d failed", attempt)
return {
"text": best[0], "rate_ratio": best[1],
"attempts": attempt - 1, "error": "fit-provider-failed",
}
if next_text and next_text.strip():
candidate = next_text.strip()
+100 -1
View File
@@ -40,6 +40,7 @@ from __future__ import annotations
import atexit
import base64
import contextlib
import json
import logging
import os
@@ -263,6 +264,91 @@ def _ensure_reaper_running() -> None:
# ── Base class ─────────────────────────────────────────────────────────────
#: How often to prove liveness while an engine's venv is being resolved.
#: Matches the sidecar's own cold-load cadence (#1367) so the guarded waiter
#: sees the same rhythm from both steps.
_RESOLVE_HEARTBEAT_S = 5.0
@contextlib.contextmanager
def _heartbeat_while_resolving(engine_id: str):
"""Report progress while a slow engine-venv resolution runs (#1414).
A probe that spawns interpreters, or a bootstrap that installs torch, can
outlast the generate budget on its own. Both are demonstrably *working*
the whole time, so the deadline should extend rather than expire which
is what the execution clock's load heartbeat is for.
Two details that are easy to get wrong:
* **Pool jobs only.** An off-pool caller never runs on a thread the clock
tracks, and heartbeating from one would credit an ident a pool worker
might later reuse up to a grace period of unearned extension, which
is the #1379 lesson.
* **The resolving thread's ident, not the beater's.** The heartbeat runs
on a helper thread so it can tick while resolution blocks, but the job
the clock is watching is the caller's. Capturing the ident up front is
what makes the extension land on the right job.
Never raises: a failed heartbeat must not fail a generation.
"""
try:
from services.model_manager import running_on_gpu_pool
on_pool = running_on_gpu_pool()
except Exception: # noqa: BLE001 — best-effort by construction
on_pool = False
if not on_pool:
yield
return
ident = threading.get_ident()
stop = threading.Event()
def _beat():
try:
from services.model_manager import (
MODEL_LOAD_HEARTBEAT_GRACE_S, _MODEL_LOAD_ACTIVITY,
)
except Exception: # noqa: BLE001
return
while not stop.wait(_RESOLVE_HEARTBEAT_S):
try:
_MODEL_LOAD_ACTIVITY[ident] = (
time.monotonic(), MODEL_LOAD_HEARTBEAT_GRACE_S,
)
except Exception: # noqa: BLE001
return
t = threading.Thread(
target=_beat, name=f"{engine_id}-resolve-heartbeat", daemon=True,
)
t.start()
try:
yield
finally:
stop.set()
# Join, don't just signal. `_beat()` can be past its `stop.wait()` and
# already committed to a write at the moment the flag is set, so
# signalling alone lets that write land at an arbitrary later point.
#
# That matters because of what runs next: `_run_on_gpu_pool`'s `_job`
# pops this ident from `_MODEL_LOAD_ACTIVITY` in its `finally`
# (model_manager.py) precisely so a stale beat cannot vouch for a
# future job — GPU-pool idents are reused. A write arriving after that
# pop resurrects the entry, and the next job scheduled onto this
# worker inherits a heartbeat it never emitted: the wedge detector
# reads it as live progress and keeps extending a job that is stuck.
#
# Joining orders the last write BEFORE the pop, so the pop clears it.
# This must not be bounded: returning while the helper is still alive
# would recreate the late-write race this join closes. The helper's
# only work after ``wait`` is an in-memory mapping assignment guarded
# by its own broad exception handler, so there is no blocking external
# operation to time out here.
t.join()
class SubprocessBackend(TTSBackend):
"""Long-lived sidecar-process TTS backend. Subclasses provide
``venv_python()`` and ``sidecar_script()``; the base class owns
@@ -379,7 +465,20 @@ class SubprocessBackend(TTSBackend):
else:
kwargs["start_new_session"] = True
python_path = str(self.venv_python())
# `venv_python()` resolves the engine's interpreter, and on a cold
# first run that is not cheap: it spawns each candidate to import the
# engine (bounded, but tens of seconds on a slow disk), and if none is
# installed it can run the whole `uv venv` + `uv pip install`
# bootstrap — minutes, by design.
#
# All of that happens on a GPU-pool worker, inside a generate request
# whose execution budget is 300s by default. Nothing along the way
# reported progress, so the budget expired mid-install and the job was
# abandoned and blamed on the machine's compute (#1414). The sidecar's
# own cold load already heartbeats for exactly this reason (#1367);
# resolution is the step before it that never did.
with _heartbeat_while_resolving(self.id):
python_path = str(self.venv_python())
script_path = str(self.sidecar_script())
# #1172 class: validate the interpreter before exec so a broken /
# half-installed engine venv (0-byte or truncated python, dangling
+2 -1
View File
@@ -180,7 +180,8 @@ class TestDeleteJob:
assert client.get(f"/batch/jobs/{r['job_id']}").status_code == 404
def test_delete_not_found(self, client):
assert client.delete("/batch/jobs/nope").status_code == 404
response = client.delete("/batch/jobs/nope")
assert response.status_code == 404
class TestSetProgress:
+4 -2
View File
@@ -15,6 +15,7 @@ import os
import subprocess
import sys
import time
from pathlib import Path
import pytest
from fastapi import FastAPI
@@ -244,12 +245,13 @@ def test_version_gate_hides_other_release_records_and_reads_never_write(sentinel
store["records"][0]["version"] = "0.0.1"
with open(run_sentinel.CRASH_RECORD_PATH, "w", encoding="utf-8") as f:
json.dump(store, f)
before = open(run_sentinel.CRASH_RECORD_PATH, "rb").read()
before = Path(run_sentinel.CRASH_RECORD_PATH).read_bytes()
assert run_sentinel.newest_record("9.9.9") is None, "other release = stale"
# Preview stamps match their base release (X.Y.Z-N == X.Y.Z).
assert run_sentinel.newest_record("0.0.1-7") is not None
assert open(run_sentinel.CRASH_RECORD_PATH, "rb").read() == before, (
actual = Path(run_sentinel.CRASH_RECORD_PATH).read_bytes()
assert actual == before, (
"the read path must never write (crash.rs read-only contract)"
)
# Versionless legacy records never surface either.
+15 -5
View File
@@ -21,7 +21,8 @@ tools keep working unchanged whichever gate is set.
> VoiceStudio separates **consumption** (TTS, dictation, voices) from
> **administration** (`/system/*`, `/api/settings/*` — RCE-class). The PIN and
> trusted networks are *consumption* credentials; the **admin surface is only
> ever reached from loopback or with the API key** (see [Admin routes](#admin-routes-and-server-mode)).
> ever reached from loopback or with the API key**. Host-path capabilities stay
> desktop-only even with a key (see [Admin routes](#admin-routes-and-server-mode)).
> Both gates can be active at once. The PIN and the API key are independent; when
> both are set, each is checked on the paths it covers.
@@ -206,13 +207,22 @@ origin is unenforceable — NAT rewrites the source and even a
requirement is dropped (issue #261, else the operator is 403'd out of their own
`/system/*`). It is replaced by a **credential rule**, not removed:
- **No credential configured** (no API key, no PIN) → admin is open. The bare
Docker flow; exposure rests entirely on your port mapping / firewall.
- **No API key configured** → read-only admin discovery remains available for
the bare Docker bootstrap flow, but `POST`/`PUT`/`PATCH`/`DELETE` requests are
denied. Set `OMNIVOICE_API_KEY` before changing settings remotely.
- **A credential is configured** → admin requires the **API key** (`Authorization:
Bearer` / `?api_key` / `ov_key` cookie), or genuine loopback. The **6-digit
share PIN does not gate admin** (it is brute-forceable), and trusted-network
membership never does either. So a **PIN-only** server-mode deployment keeps
admin loopback-only; remote admin requires the long API key.
membership never does either. A **PIN-only** server-mode deployment therefore
allows remote read-only discovery but blocks remote mutations; remote writes
require the long API key. Discovery never returns the share PIN itself; only
loopback or a caller already authenticated with the API key can read it.
Host paths are never selected through HTTP. The native Tauri process validates
model-cache destinations and custom FFmpeg/FFprobe binaries, writes a private
one-shot capability, and only that opaque authorization reaches the backend.
`/system/set-env` does not accept executable-path keys at all. Server mode and
an API key do not weaken that native boundary.
This is the fix for a real escalation (#1213): before it, server mode made the
admin gate a no-op, so with an API key set *and* a trusted CIDR configured, a LAN
+1
View File
@@ -2947,6 +2947,7 @@ dependencies = [
"dirs-next",
"enigo",
"fs4",
"getrandom 0.3.4",
"libc",
"log",
"reqwest",
+1
View File
@@ -22,6 +22,7 @@ tauri-build = { version = "2.6.0", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
getrandom = "0.3"
log = "0.4"
tauri = { version = "2.11.0", features = ["macos-private-api", "protocol-asset", "tray-icon", "image-png"] }
tauri-plugin-log = "2"
+107 -1
View File
@@ -5,13 +5,119 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::time::Duration;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use tauri::image::Image;
use crate::{AppFlags, TrayHandle, DictationShortcutState};
use crate::{TRAY_ICON_DEFAULT, TRAY_ICON_RECORDING};
use crate::config::{load_config, save_config};
// ── Native host-path authorization ───────────────────────────────────────
#[derive(Serialize, Deserialize)]
struct AuthorizedHostPath {
token: String,
kind: String,
path: String,
}
pub fn path_authorization_dir<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> PathBuf {
crate::setup::resolved_data_dir(app)
.unwrap_or_else(crate::setup::default_data_dir)
.join(".path-authorizations")
}
fn validate_host_path(kind: &str, raw: &str) -> Result<PathBuf, String> {
if !matches!(kind, "models_dir" | "ffmpeg" | "ffprobe") {
return Err("Unsupported host-path capability".into());
}
if raw.chars().any(|c| c.is_control()) {
return Err("Path contains invalid control characters".into());
}
if kind == "models_dir" && raw.is_empty() {
return Ok(PathBuf::new()); // explicit reset to the platform default
}
let path = PathBuf::from(raw);
if !path.is_absolute() {
return Err("Path must be absolute".into());
}
if kind == "models_dir" {
fs::create_dir_all(&path).map_err(|e| format!("Directory is not writable: {e}"))?;
let probe = path.join(".voicestudio-write-test");
fs::write(&probe, b"ok").map_err(|e| format!("Directory is not writable: {e}"))?;
let _ = fs::remove_file(probe);
} else {
if !path.is_file() {
return Err("Selected media tool is not a file".into());
}
let status = crate::tools::no_window(
std::process::Command::new(&path)
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()),
)
.status()
.map_err(|e| format!("Selected media tool could not run: {e}"))?;
if !status.success() {
return Err("Selected media tool failed its version check".into());
}
}
Ok(path)
}
#[tauri::command]
pub fn authorize_host_path(
app: tauri::AppHandle,
kind: String,
path: String,
) -> Result<String, String> {
let validated = validate_host_path(&kind, path.trim())?;
let mut random = [0_u8; 32];
getrandom::fill(&mut random).map_err(|e| format!("Secure randomness unavailable: {e}"))?;
let token: String = random.iter().map(|b| format!("{b:02x}")).collect();
let dir = path_authorization_dir(&app);
fs::create_dir_all(&dir).map_err(|e| format!("Could not create authorization store: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))
.map_err(|e| format!("Could not protect authorization store: {e}"))?;
}
let target = dir.join(format!("{token}.json"));
let payload = AuthorizedHostPath {
token: token.clone(),
kind,
path: validated.to_string_lossy().into_owned(),
};
fs::write(&target, serde_json::to_vec(&payload).map_err(|e| e.to_string())?)
.map_err(|e| format!("Could not authorize path: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&target, fs::Permissions::from_mode(0o600))
.map_err(|e| format!("Could not protect authorization: {e}"))?;
}
Ok(token)
}
#[cfg(test)]
mod host_path_authorization_tests {
use super::validate_host_path;
use std::path::PathBuf;
#[test]
fn rejects_unknown_relative_and_control_character_paths() {
assert!(validate_host_path("shell", "/tmp/tool").is_err());
assert!(validate_host_path("models_dir", "relative/models").is_err());
assert!(validate_host_path("models_dir", "/tmp/bad\npath").is_err());
}
#[test]
fn empty_models_path_is_the_authorized_default_reset() {
assert_eq!(validate_host_path("models_dir", "").unwrap(), PathBuf::new());
}
}
// ── System metrics ────────────────────────────────────────────────────────
#[derive(Serialize, Clone)]
+1
View File
@@ -422,6 +422,7 @@ pub fn run() {
updater_channel::install_update,
updater_channel::list_releases,
commands::get_sysinfo,
commands::authorize_host_path,
commands::read_log_tail,
commands::hf_cache_scan,
commands::simulate_paste,
@@ -267,7 +267,27 @@ export default function AudioToolsPanel() {
const onToolAction = useCallback(
async (path, body) => {
const ok = await post(path, body);
let requestBody = body;
if (path.endsWith('/custom-path')) {
try {
const { invoke } = await import('@tauri-apps/api/core');
const tool = path.includes('/ffprobe/') ? 'ffprobe' : 'ffmpeg';
const authorization = await invoke('authorize_host_path', {
kind: tool,
path: body?.path || '',
});
requestBody = { authorization };
} catch (e) {
toast.error(
t('settings.audio_tools_path_failed', {
message: e.message || String(e),
defaultValue: "Couldn't set path: {{message}}",
}),
);
return;
}
}
const ok = await post(path, requestBody);
if (ok && (path.endsWith('/custom-path') || path.endsWith('/use-system'))) {
toast.success(
t('settings.audio_tools_path_set', {
@@ -11,6 +11,8 @@ vi.mock('../../api/client', () => ({
apiJson: vi.fn(),
apiFetch: vi.fn(),
}));
const invoke = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args) => invoke(...args) }));
import { toast } from 'react-hot-toast';
import { apiJson, apiFetch } from '../../api/client';
@@ -57,6 +59,7 @@ describe('AudioToolsPanel — power-user surface for the media tools', () => {
vi.clearAllMocks();
apiJson.mockResolvedValue(JSON.parse(JSON.stringify(STATUS)));
apiFetch.mockResolvedValue(okResponse);
invoke.mockResolvedValue('c'.repeat(64));
});
it('renders one row per tool with version, path, and origin badge', async () => {
@@ -85,6 +88,26 @@ describe('AudioToolsPanel — power-user surface for the media tools', () => {
await waitFor(() => expect(toast.success).toHaveBeenCalled());
});
it('sends only a native one-shot authorization for a custom executable', async () => {
render(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('FFmpeg: Choose file…'));
const input = await screen.findByLabelText('FFmpeg binary path');
fireEvent.change(input, { target: { value: '/opt/tools/ffmpeg' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('authorize_host_path', {
kind: 'ffmpeg',
path: '/opt/tools/ffmpeg',
}),
);
expect(apiFetch).toHaveBeenCalledWith(
'/media-tools/ffmpeg/custom-path',
expect.objectContaining({ body: JSON.stringify({ authorization: 'c'.repeat(64) }) }),
);
expect(apiFetch.mock.calls.flat().join(' ')).not.toContain('/opt/tools/ffmpeg');
});
it('Restore bundled is per-tool and always available (safe revert)', async () => {
render(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('FFprobe: Restore bundled'));
@@ -9,7 +9,7 @@
* Endpoints:
* GET /api/settings/storage/models-dir
* {configured, effective, default, restart_required}
* PUT /api/settings/storage/models-dir body {path} (empty path clears)
* Native IPC authorizes the path, then PUT sends only the one-shot token.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { HardDrive } from 'lucide-react';
@@ -52,10 +52,12 @@ export default function StoragePanel() {
setSaving(true);
setError(null);
try {
const { invoke } = await import('@tauri-apps/api/core');
const authorization = await invoke('authorize_host_path', { kind: 'models_dir', path });
const res = await apiFetch('/api/settings/storage/models-dir', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
body: JSON.stringify({ authorization }),
});
if (!res.ok) {
const b = await res.json().catch(() => ({}));
@@ -0,0 +1,43 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
vi.mock('react-hot-toast', () => ({ default: { success: vi.fn() } }));
const apiJson = vi.fn();
const apiFetch = vi.fn();
vi.mock('../../api/client', () => ({
apiJson: (...args) => apiJson(...args),
apiFetch: (...args) => apiFetch(...args),
}));
const invoke = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args) => invoke(...args) }));
import StoragePanel from './StoragePanel';
describe('StoragePanel native path boundary', () => {
it('never sends the selected models directory through HTTP', async () => {
apiJson.mockResolvedValue({ configured: '', effective: '/cache', default: '/default' });
apiFetch.mockResolvedValue({
ok: true,
json: async () => ({ configured: '/private/models', restart_required: true }),
});
invoke.mockResolvedValue('d'.repeat(64));
render(<StoragePanel />);
const input = await screen.findByTestId('models-dir-input');
fireEvent.change(input, { target: { value: '/private/models' } });
fireEvent.click(screen.getByTestId('models-dir-save'));
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('authorize_host_path', {
kind: 'models_dir',
path: '/private/models',
}),
);
expect(apiFetch).toHaveBeenCalledWith(
'/api/settings/storage/models-dir',
expect.objectContaining({ body: JSON.stringify({ authorization: 'd'.repeat(64) }) }),
);
expect(apiFetch.mock.calls.flat().join(' ')).not.toContain('/private/models');
});
});
+6 -1
View File
@@ -108,7 +108,12 @@ def sanitize_instruct(raw):
"""
if not raw:
return ""
return _valid_instruct_from_items(re.split(r"\s*[,]\s*", str(raw).strip()))
# Strip each comma-delimited item after splitting. A pattern with ``\s*``
# on both sides of the delimiter backtracks quadratically when a poisoned
# stored value contains a long whitespace run without a comma (GHAS #778).
return _valid_instruct_from_items(
item.strip() for item in re.split(r"[,]", str(raw).strip())
)
def instruct_from_vd_states(vd_states):
@@ -20,6 +20,7 @@ from __future__ import annotations
import re
import sys
from time import perf_counter
import pytest
@@ -420,6 +421,45 @@ def test_select_mlx_audio_raw_repo_id_accepted(fresh_app, monkeypatch):
assert _prefs.get("mlx_audio_model_id") == "mlx-community/Some-Other-Model-4bit"
def test_select_mlx_audio_repo_id_accepts_underscore_prefixes(fresh_app, monkeypatch):
_make_mlx_audio_available(monkeypatch)
r = _client(fresh_app).post(
"/engines/select",
json={
"family": "tts", "backend_id": "mlx-audio",
"model_id": "_owner/_repo",
},
)
assert r.status_code == 200, r.text
@pytest.mark.parametrize(
"model_id",
[
"owner/repo/extra",
"-owner/repo",
"owner/.repo",
"owner/repo--name",
"owner/repo..name",
"owner/repo.",
"owner/repo.git",
f"owner/{'a' * 97}",
"-" * 100_000,
],
)
def test_select_mlx_audio_rejects_malformed_repo_ids(fresh_app, monkeypatch, model_id):
_make_mlx_audio_available(monkeypatch)
started = perf_counter()
r = _client(fresh_app).post(
"/engines/select",
json={"family": "tts", "backend_id": "mlx-audio", "model_id": model_id},
)
assert r.status_code == 400
assert perf_counter() - started < 0.5
assert len(r.content) < 256
assert model_id[:100] not in r.text
def test_select_mlx_audio_without_model_id_does_not_touch_pref(fresh_app, monkeypatch):
"""Selecting mlx-audio without a model_id (e.g. an older frontend) must
leave any existing mlx_audio_model_id pref untouched."""
+49 -1
View File
@@ -600,6 +600,55 @@ def test_set_env_allows_loopback():
os.environ["HF_TOKEN"] = original
def test_server_mode_remote_without_api_key_cannot_set_executable_path(monkeypatch, tmp_path):
"""GHAS #506: bare Docker exposure must not become an executable setter."""
from fastapi.testclient import TestClient
from main import app
executable = tmp_path / "ffmpeg"
executable.write_bytes(b"not actually executable")
original = os.environ.get("FFMPEG_PATH")
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
try:
response = TestClient(app, client=("172.17.0.1", 50000)).post(
"/system/set-env",
json={"key": "FFMPEG_PATH", "value": str(executable)},
)
assert response.status_code == 403
assert os.environ.get("FFMPEG_PATH") == original
finally:
if original is None:
os.environ.pop("FFMPEG_PATH", None)
else:
os.environ["FFMPEG_PATH"] = original
def test_set_env_never_accepts_executable_path_even_with_admin_key(monkeypatch, tmp_path):
"""Executable selection exists only behind native IPC, never this API."""
from fastapi.testclient import TestClient
from main import app
executable = tmp_path / "ffmpeg"
executable.write_bytes(b"not actually executable")
original = os.environ.get("FFMPEG_PATH")
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret")
try:
response = TestClient(app, client=("172.17.0.1", 50000)).post(
"/system/set-env",
headers={"authorization": "Bearer s3cret"},
json={"key": "FFMPEG_PATH", "value": str(executable)},
)
assert response.status_code == 400
assert os.environ.get("FFMPEG_PATH") == original
finally:
if original is None:
os.environ.pop("FFMPEG_PATH", None)
else:
os.environ["FFMPEG_PATH"] = original
def test_set_env_loopback_still_validates_allowlist():
"""Even on the loopback path, keys outside the allow-list must return 400 —
the new guard must NOT bypass the existing allow-list enforcement."""
@@ -686,4 +735,3 @@ def test_static_audio_served_with_canonical_mime():
)
finally:
tmp_wav.unlink(missing_ok=True)
+2 -1
View File
@@ -366,7 +366,8 @@ class TestEndpoints:
_offline_asr_missing(cached=True):
r = client.get("/dub/transcribe-stream/j4")
assert r.status_code == 200
assert "audio load failed" in r.text
assert "Transcription failed. Check the selected ASR engine and try again." in r.text
assert str(wav) not in r.text
backend.unload.assert_called_once()
+76 -4
View File
@@ -160,7 +160,9 @@ def test_transcribe_stream_surfaces_model_load_failure(tmp_path, monkeypatch):
assert "CUDA driver init failed: simulated" in body, body
def test_transcribe_stream_never_closes_without_terminal_event(tmp_path, monkeypatch):
def test_transcribe_stream_never_closes_without_terminal_event(
tmp_path, monkeypatch, caplog
):
"""Regression #516: an unanticipated exception INSIDE the stream body (one
that escapes the per-chunk handler, e.g. segmentation blowing up) must still
end the stream with a terminal `error` then `done` never a silent
@@ -203,7 +205,7 @@ def test_transcribe_stream_never_closes_without_terminal_event(tmp_path, monkeyp
# Make the post-chunk segmentation (outside the per-chunk try/except) blow
# up — the exact class of "unanticipated escape" the guard must catch.
def _boom_segment(*a, **k):
raise RuntimeError("segmentation exploded: simulated")
raise RuntimeError("API_KEY=dub-secret /home/alice/private-video.mp4")
monkeypatch.setattr(dc, "segment_transcript", _boom_segment)
# Don't touch the GPU/TTS during the test.
monkeypatch.setattr(dc, "offload_tts_for_asr", lambda *a, **k: None)
@@ -222,12 +224,79 @@ def test_transcribe_stream_never_closes_without_terminal_event(tmp_path, monkeyp
# The stream must end with a terminal error followed by done.
assert "event: error" in body, body
assert "segmentation exploded: simulated" in body, body
assert "transcription_failed" in body, body
assert "Transcription failed. Check the selected ASR engine and try again." in body, body
assert "dub-secret" not in body, body
assert "Traceback" not in body, body
assert "dub-secret" not in caplog.text
assert "/home/alice/private-video.mp4" not in caplog.text
err_idx = body.rfind("event: error")
done_idx = body.rfind("event: done")
assert done_idx > err_idx >= 0, f"error must precede the terminal done: {body}"
def test_transcribe_chunk_failure_uses_stable_public_metadata(
tmp_path, monkeypatch, caplog
):
"""Inner ASR failures must not serialize provider secrets or local paths."""
import asyncio
from api.routers import dub_core as dc
job_id = "t_chunk_secret"
audio = tmp_path / "a.wav"
_make_wav(audio, seconds=1.0)
dc._dub_jobs[job_id] = {
"audio_path": str(audio), "vocals_path": None, "scene_cuts": [],
}
fake_model = MagicMock()
fake_model._asr_pipe = MagicMock()
async def _ok_model():
return fake_model
class _FailingASR:
id = "fake"
def ensure_loaded(self):
pass
def transcribe(self, path, *, word_timestamps=True):
raise RuntimeError("TOKEN=chunk-secret /home/alice/private-audio.wav")
def unload(self):
pass
monkeypatch.setattr(dc, "get_model", _ok_model)
monkeypatch.setattr(dc, "_CHUNK_TRANSCRIBE_ATTEMPTS", 1)
monkeypatch.setattr(dc, "offload_tts_for_asr", lambda *a, **k: None)
monkeypatch.setattr(
"services.asr_backend.get_active_asr_backend",
lambda *a, **k: _FailingASR(),
)
async def _collect():
resp = await dc.dub_transcribe_stream(job_id)
parts = []
async for chunk in resp.body_iterator:
parts.append(
chunk.decode() if isinstance(chunk, (bytes, bytearray)) else str(chunk)
)
return "".join(parts)
try:
body = asyncio.run(_collect())
finally:
dc._dub_jobs.pop(job_id, None)
assert "transcription_failed" in body, body
assert "Transcription failed. Check the selected ASR engine and try again." in body
assert "chunk-secret" not in body
assert "/home/alice/private-audio.wav" not in body
assert "chunk-secret" not in caplog.text
assert "/home/alice/private-audio.wav" not in caplog.text
def test_transcribe_stream_surfaces_asr_load_failure_at_preflight(tmp_path, monkeypatch):
"""Regression #578: the reported failure mode is the *ASR model* failing to
load (WhisperX: faster-whisper weights / CTranslate2-cuDNN mismatch / the
@@ -334,7 +403,10 @@ def test_transcribe_stream_preflight_crash_is_a_structured_error(monkeypatch):
body = asyncio.run(_collect())
assert "event: error" in body, body
assert "job store exploded: simulated" in body, body
assert "transcription_failed" in body, body
assert "Transcription failed. Check the selected ASR engine and try again." in body, body
assert "job store exploded: simulated" not in body, body
assert "Traceback" not in body, body
err_idx = body.rfind("event: error")
done_idx = body.rfind("event: done")
assert done_idx > err_idx >= 0, f"error must precede the terminal done: {body}"
+24
View File
@@ -79,6 +79,30 @@ def test_hint_only_reorders_never_drops(er):
assert set(prober.calls) == {er.CANONICAL_ENDPOINT, er.COMMUNITY_MIRROR}
@pytest.mark.parametrize(
"endpoint",
[
"file:///etc/passwd",
"http://huggingface.co",
"https://huggingface.co.evil.example",
"https://huggingface.co@evil.example",
"https://huggingface.co:444",
"https://huggingface.co/model",
"https://huggingface.co?redirect=file:///etc/passwd",
],
)
def test_probe_rejects_unapproved_origins(er, endpoint):
# The suite-wide network guard replaces the actual prober, so exercise the
# validation chokepoint directly. Both real network helpers call it before
# constructing a Request or reaching urlopen.
assert er._is_allowed_probe_endpoint(endpoint) is False
@pytest.mark.parametrize("endpoint", ["https://huggingface.co", "https://hf-mirror.com/"])
def test_probe_allows_only_shipped_https_origins(er, endpoint):
assert er._is_allowed_probe_endpoint(endpoint) is True
# ── Decision policy matrix ──────────────────────────────────────────────────
def test_both_reachable_similar_latency_prefers_canonical(er):
+13 -4
View File
@@ -111,7 +111,9 @@ def _make_deterministic_engine(engine_id="stream-fake", *, delay_s=0.0,
def generate(self, text, **kw) -> torch.Tensor:
type(self).calls.append((text, time.monotonic()))
if fail_on_call is not None and len(type(self).calls) == fail_on_call:
raise RuntimeError("engine exploded mid-stream (test)")
raise RuntimeError(
"TOKEN=stream-secret /home/alice/private-reference.wav"
)
if delay_s:
time.sleep(delay_s)
# Deterministic, text-dependent waveform (crc-seeded sine-ish ramp).
@@ -277,8 +279,9 @@ def test_stream_final_file_identical_to_classic_output(client, monkeypatch,
assert done["duration"] > 0
def test_stream_midstream_error_yields_error_event(client, monkeypatch,
no_omnivoice_model):
def test_stream_midstream_error_yields_error_event(
client, monkeypatch, no_omnivoice_model, caplog
):
"""Chunk 2 blowing up must surface as an in-band error event AFTER the
already-delivered chunk no done, no history row, no saved file."""
fake = _make_deterministic_engine(fail_on_call=2)
@@ -294,7 +297,13 @@ def test_stream_midstream_error_yields_error_event(client, monkeypatch,
assert "chunk" in types # chunk 0 was delivered before the crash
assert types[-1] == "error"
assert "done" not in types
assert events[-1][0]["detail"] # actionable message for the fallback log
error = events[-1][0]
assert error["code"] == "generation_failed"
assert error["detail"] == "Generation failed. Check the selected engine and try again."
assert "stream-secret" not in repr(error)
assert "Traceback" not in repr(error)
assert "stream-secret" not in caplog.text
assert "/home/alice/private-reference.wav" not in caplog.text
after_ids = {h["id"] for h in client.get("/history").json()}
assert after_ids == before_ids # nothing was recorded for the failure
@@ -0,0 +1,161 @@
"""Regression evidence for reviewed GHAS false positives.
These tests intentionally inspect the narrow security invariants at the
reported sinks. They keep future refactors from making a dismissed alert
silently become exploitable while avoiding heavyweight model imports.
"""
from __future__ import annotations
import ast
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _source(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def _function(path: str, name: str) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, str]:
source = _source(path)
tree = ast.parse(source)
node = next(
item
for item in ast.walk(tree)
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == name
)
return node, ast.get_source_segment(source, node) or ""
def test_dynamic_updates_only_interpolate_allowlisted_columns_and_placeholders():
cases = (
(
"backend/api/routers/glossary.py",
"update_term",
'("source", "target", "note")',
"UPDATE glossary_terms",
),
(
"backend/api/routers/profiles.py",
"update_profile",
'("name", "ref_text", "instruct", "language", "personality")',
"UPDATE voice_profiles",
),
)
for path, function, allowlist, table in cases:
_, body = _function(path, function)
assert allowlist in body
assert 'fields.append(f"{col} = ?")' in body
assert table in body
assert "params" in body
# User values and IDs are never formatted into the statement.
assert "{val}" not in body
assert "{profile_id}" not in body
assert "{term_id}" not in body
assert "{project_id}" not in body
def test_history_reference_query_varies_only_placeholder_arity():
_, body = _function(
"backend/api/routers/generation.py", "_remove_wav_if_unreferenced"
)
assert 'placeholders = ",".join("?" for _ in exclude_ids)' in body
assert 'f" AND id NOT IN ({placeholders})"' in body
assert "(audio_path, *exclude_ids)" in body
assert "{audio_path}" not in body
assert "{exclude_ids}" not in body
def test_media_download_sink_requires_https_size_and_sha256():
_, body = _function("backend/services/media_tools.py", "_download")
assert 'if not url.startswith("https://")' in body
assert "done != expected_size" in body
assert "digest != expected_sha256" in body
assert body.index('url.startswith("https://")') < body.index("urlopen(")
def test_pypi_metadata_and_wheel_digest_reach_the_verified_download_sink():
source = _source("backend/services/media_tools.py")
assert '_PYPI_YTDLP_URL = "https://pypi.org/pypi/yt-dlp/json"' in source
_, fetch = _function("backend/services/media_tools.py", "_fetch_pypi_ytdlp")
assert 'artifact["url"]' in fetch
assert 'artifact["digests"]["sha256"]' in fetch
_, update = _function("backend/services/media_tools.py", "_do_update_ytdlp")
assert "version, url, sha = _fetch_pypi_ytdlp()" in update
assert '_download(url, whl, sha, None, op="ytdlp_update")' in update
def test_diagnostic_probe_is_a_guarded_constant_https_head_request():
source = _source("backend/core/diagnose.py")
assert '_HUB_URL = "https://huggingface.co"' in source
_, body = _function("backend/core/diagnose.py", "_check_network")
assert 'if not _HUB_URL.startswith("https://")' in body
assert 'Request(_HUB_URL, method="HEAD")' in body
assert body.index('_HUB_URL.startswith("https://")') < body.index("urlopen(")
def test_health_check_url_and_server_are_both_pinned_to_loopback():
source = _source("backend/main.py")
assert 'HEALTH_URL = f"http://127.0.0.1:{_port}/health"' in source
assert 'uvicorn.run(app, host="127.0.0.1", port=_port' in source
assert "_port = network_share.backend_port()" in source
def test_huggingface_cache_probe_is_forced_offline():
_, body = _function("backend/services/model_manager.py", "_checkpoint_in_local_cache")
assert "snapshot_download(checkpoint, local_files_only=True)" in body
assert "local_files_only=False" not in body
def test_pep562_exports_are_backed_by_lazy_attribute_resolvers():
package = _source("omnivoice/__init__.py")
assert '__all__ = ["OmniVoice", "OmniVoiceConfig", "OmniVoiceGenerationConfig"]' in package
assert "def __getattr__(name):" in package
assert "if name in __all__:" in package
assert "return getattr(_m, name)" in package
backend = _source("backend/engines/omnivoice_gguf/backend.py")
assert '"OmniVoiceGGUFBackend",' in backend
assert 'if name == "OmniVoiceGGUFBackend":' in backend
assert "return _make_backend_class()" in backend
def test_secret_error_logs_never_include_plaintext_or_ciphertext_variables():
_, body = _function("backend/services/settings_store.py", "get_secret")
tree = ast.parse(body)
log_calls = [
call
for call in ast.walk(tree)
if isinstance(call, ast.Call)
and isinstance(call.func, ast.Attribute)
and call.func.attr in {"error", "warning", "exception"}
]
assert len(log_calls) == 3
for call in log_calls:
argument_names = {
node.id for arg in call.args[1:] for node in ast.walk(arg) if isinstance(node, ast.Name)
}
assert argument_names <= {"name"}
rendered = ast.unparse(call)
assert "row" not in rendered
assert "key" not in rendered
def test_dataset_script_handles_are_closed_by_outer_finally_blocks():
cases = (
("omnivoice/scripts/denoise_audio.py", "main"),
("omnivoice/scripts/extract_audio_tokens.py", "main"),
("omnivoice/scripts/extract_audio_tokens_add_noise.py", "main"),
)
for path, function in cases:
_, body = _function(path, function)
assert "tar_writer = None" in body
assert "jsonl_file = None" in body
assert "finally:" in body
finally_body = body.rsplit("finally:", 1)[1]
assert "if tar_writer is not None:" in finally_body
assert "tar_writer.close()" in finally_body
assert "if jsonl_file is not None:" in finally_body
assert "jsonl_file.close()" in finally_body
+14 -1
View File
@@ -179,7 +179,9 @@ def test_probe_failure_detail_is_scrubbed(settings_mod, monkeypatch):
"boom key=gsk-test-123 at /Users/someone/secret"))
body = settings_mod.test_llm_provider("groq")
assert body["ok"] is False
assert "gsk-test-123" not in body["detail"]
assert body["detail"] == "The provider request failed. Try again."
assert "gsk-test-123" not in repr(body)
assert "/Users/someone" not in repr(body)
# ── /models discovery ───────────────────────────────────────────────────────
@@ -204,6 +206,17 @@ def test_models_failure_is_classified(settings_mod, monkeypatch):
_fake_openai(monkeypatch, raise_exc=exc)
body = settings_mod.list_llm_provider_models("groq")
assert body["ok"] is False and body["kind"] == "auth" and body["models"] == []
assert body["detail"] == "Authentication failed. Check the provider API key."
def test_models_failure_omits_trace_path_and_secret(settings_mod, monkeypatch):
_configure_groq(settings_mod)
private = "Traceback: key=gsk-test-123 at /home/alice/provider.py"
_fake_openai(monkeypatch, raise_exc=RuntimeError(private))
body = settings_mod.list_llm_provider_models("groq")
assert body["kind"] == "error"
assert body["detail"] == "The provider request failed. Try again."
assert private not in repr(body)
def test_models_not_truncated_under_cap(settings_mod, monkeypatch):
+36
View File
@@ -268,6 +268,24 @@ def test_disabled_direction_parse_uses_heuristic(skills, store, monkeypatch):
assert d.tokens.get("energy") == ["urgent"] # heuristic still delivers
def test_direction_failure_returns_stable_error(skills, store, monkeypatch, caplog):
_activate_groq(store)
from services import director
private = "Traceback: token=private-value at /home/alice/director.py"
class _Fake:
id = "openai-compat"
def chat(self, **kw):
raise RuntimeError(private)
monkeypatch.setattr(director, "get_active_llm_backend", lambda: _Fake())
d = director.parse("urgent and surprised")
assert d.error == "llm-parse-failed"
assert private not in repr(d)
assert private not in caplog.text
def test_disabled_slot_fitting_returns_no_llm_marker(skills, store, monkeypatch):
_activate_groq(store)
from services import speech_rate
@@ -287,6 +305,24 @@ def test_disabled_slot_fitting_returns_no_llm_marker(skills, store, monkeypatch)
assert res["error"] == "no-llm" and res["text"] == long_text
def test_slot_fit_failure_returns_stable_error(skills, store, monkeypatch, caplog):
_activate_groq(store)
from services import speech_rate
private = "Traceback: token=private-value at /home/alice/rate.py"
class _Fake:
id = "openai-compat"
def chat(self, **kw):
raise RuntimeError(private)
monkeypatch.setattr(speech_rate, "get_active_llm_backend", lambda: _Fake())
res = speech_rate.adjust_for_slot("x" * 30, slot_seconds=1.0, target_lang="en")
assert res["error"] == "fit-provider-failed"
assert private not in repr(res)
assert private not in caplog.text
def test_disabled_glossary_extract_503s(skills, store):
_activate_groq(store)
from fastapi import HTTPException
+88 -2
View File
@@ -10,7 +10,36 @@ from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from api.dependencies import is_loopback, is_local_host, require_local, require_loopback
def _dependency(name):
# Resolve at test execution time: other suites intentionally replace
# ``api.*`` modules in sys.modules while probing cold-start behavior.
from api import dependencies
return getattr(dependencies, name)
def is_loopback(*args, **kwargs):
return _dependency("is_loopback")(*args, **kwargs)
def is_local_host(*args, **kwargs):
return _dependency("is_local_host")(*args, **kwargs)
def require_admin(*args, **kwargs):
return _dependency("require_admin")(*args, **kwargs)
def require_desktop(*args, **kwargs):
return _dependency("require_desktop")(*args, **kwargs)
def require_local(*args, **kwargs):
return _dependency("require_local")(*args, **kwargs)
def require_loopback(*args, **kwargs):
return _dependency("require_loopback")(*args, **kwargs)
def _req(host):
@@ -134,7 +163,7 @@ def test_require_local_rejects_untrusted_non_loopback(monkeypatch):
assert exc.value.status_code == 403
def _req_full(host, *, headers=None, query=None, cookies=None, pin=None):
def _req_full(host, *, headers=None, query=None, cookies=None, pin=None, method="GET"):
"""Richer stub carrying the channels the admin-credential check reads:
headers, query params, cookies, and app.state.network_share.pin."""
ns = SimpleNamespace(pin=pin) if pin is not None else None
@@ -145,6 +174,7 @@ def _req_full(host, *, headers=None, query=None, cookies=None, pin=None):
query_params=query or {},
cookies=cookies or {},
app=app,
method=method,
)
@@ -231,6 +261,62 @@ def test_server_mode_loopback_admin_never_needs_credential(monkeypatch):
require_loopback(_req_full("127.0.0.1")) # must not raise
# GHAS #506/#440/#441: require_loopback permits an unconfigured bare Docker
# server for compatibility. RCE/filesystem-capable routers use the stricter,
# method-aware admin gate instead.
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"])
def test_server_mode_admin_mutation_requires_api_key_when_unconfigured(monkeypatch, method):
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
with pytest.raises(HTTPException) as exc:
require_admin(_req_full("172.17.0.1", method=method))
assert exc.value.status_code == 403
def test_server_mode_admin_read_keeps_bare_docker_bootstrap(monkeypatch):
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
require_admin(_req_full("172.17.0.1", method="GET"))
def test_server_mode_admin_read_keeps_pin_only_discovery(monkeypatch):
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
monkeypatch.setenv("OMNIVOICE_SHARE_PIN", "123456")
require_admin(_req_full("172.17.0.1", method="GET"))
def test_server_mode_admin_mutation_allows_api_key(monkeypatch):
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret")
require_admin(_req_full(
"172.17.0.1",
method="POST",
headers={"authorization": "Bearer s3cret"},
))
def test_server_mode_desktop_capability_rejects_remote_api_key(monkeypatch):
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret")
with pytest.raises(HTTPException) as exc:
require_desktop(_req_full(
"172.17.0.1",
method="POST",
headers={"authorization": "Bearer s3cret"},
))
assert exc.value.status_code == 403
@pytest.mark.parametrize("method", ["GET", "POST", "PUT", "DELETE"])
def test_loopback_admin_never_needs_api_key(monkeypatch, method):
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
require_admin(_req_full("127.0.0.1", method=method))
def test_is_local_host_unwraps_ipv4_mapped_ipv6(monkeypatch):
# Dual-stack proxies (Caddy, Node.js) pass ::ffff:192.168.1.5 — should
# match an IPv4 CIDR after unwrapping the mapped address.
+25 -3
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import hashlib
import io
import json
import os
import zipfile
from unittest.mock import patch
@@ -27,6 +28,10 @@ def mt(monkeypatch, tmp_path):
monkeypatch.setattr(prefs, "_PREFS_PATH", str(tmp_path / "prefs.json"))
monkeypatch.setattr(mt_mod, "media_tools_dir", lambda: str(tmp_path / "media_tools"))
auth_dir = tmp_path / "path-authorizations"
auth_dir.mkdir()
from core import path_authorization
monkeypatch.setattr(path_authorization, "_AUTH_DIR", str(auth_dir))
for op in mt_mod._ops.values():
op.update(state="idle", progress=0.0, error=None)
mt_mod._version_cache.clear()
@@ -382,11 +387,28 @@ def test_router_status_and_acquire_endpoints(mt, monkeypatch):
assert r.json()["state"] == "running"
def test_router_custom_path_maps_validation_to_400(mt):
def test_router_custom_path_rejects_raw_http_path(mt):
c = _client()
r = c.post("/media-tools/ffmpeg/custom-path", json={"path": "/no/such/binary"})
assert r.status_code == 400
assert "not found" in r.json()["detail"].lower()
assert r.status_code == 422
def test_router_custom_path_consumes_native_authorization(mt, monkeypatch, tmp_path):
binary = tmp_path / "ffmpeg"
binary.write_bytes(b"native-authorized")
monkeypatch.setattr(mt, "_binary_runs", lambda _path: True)
token = "b" * 64
from core import path_authorization
auth_file = os.path.join(path_authorization._AUTH_DIR, f"{token}.json")
with open(auth_file, "w", encoding="utf-8") as handle:
json.dump({"token": token, "kind": "ffmpeg", "path": str(binary)}, handle)
c = _client()
response = c.post(
"/media-tools/ffmpeg/custom-path", json={"authorization": token}
)
assert response.status_code == 200
assert os.environ["FFMPEG_PATH"] == str(binary)
assert not os.path.exists(auth_file)
def test_router_use_system_maps_lookup_to_404(mt, monkeypatch):
+75 -5
View File
@@ -8,6 +8,7 @@ second store to diverge from.
from __future__ import annotations
import os
import json
import fastapi
import pytest
@@ -24,12 +25,25 @@ def env(tmp_path, monkeypatch):
# module object — a setattr monkeypatch wouldn't reach the endpoint's copy.
envfile = str(tmp_path / "env")
monkeypatch.setenv("OMNIVOICE_ENV_FILE", envfile)
auth_dir = tmp_path / "authorizations"
auth_dir.mkdir()
from core import path_authorization
monkeypatch.setattr(path_authorization, "_AUTH_DIR", str(auth_dir))
return envfile
def _body(path, kind="models_dir"):
from core import path_authorization
auth_dir = path_authorization._AUTH_DIR
token = "a" * 64
with open(os.path.join(auth_dir, f"{token}.json"), "w", encoding="utf-8") as f:
json.dump({"token": token, "kind": kind, "path": path}, f)
return s._ModelsDirBody(authorization=token)
def test_set_persists_and_writes_durable_env(env, tmp_path):
target = str(tmp_path / "models")
res = s.set_models_dir(s._ModelsDirBody(path=target))
res = s.set_models_dir(_body(target))
abs_target = os.path.abspath(target)
assert res["configured"] == abs_target
assert res["restart_required"] is True
@@ -47,7 +61,7 @@ def test_rejects_unwritable_dir(env, monkeypatch, tmp_path):
monkeypatch.setattr(os, "makedirs", boom)
with pytest.raises(fastapi.HTTPException) as ei:
s.set_models_dir(s._ModelsDirBody(path=str(tmp_path / "ro")))
s.set_models_dir(_body(str(tmp_path / "ro")))
assert ei.value.status_code == 400
@@ -55,13 +69,69 @@ def test_rejects_path_with_null_byte(env):
# An embedded NUL would otherwise blow up os.makedirs with a ValueError
# (→ 500). Validate up front and return a clean 400 instead.
with pytest.raises(fastapi.HTTPException) as ei:
s.set_models_dir(s._ModelsDirBody(path="/tmp/mo\x00dels"))
s.set_models_dir(_body("/tmp/mo\x00dels"))
assert ei.value.status_code == 400
def test_server_mode_remote_without_api_key_cannot_create_models_dir(env, monkeypatch, tmp_path):
"""GHAS #440/#441: a published bare Docker port is not filesystem auth."""
from fastapi.testclient import TestClient
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
target = tmp_path / "must-not-exist"
app = fastapi.FastAPI()
app.include_router(s.router)
response = TestClient(app, client=("172.17.0.1", 50000)).put(
"/api/settings/storage/models-dir",
json={"path": str(target)},
)
assert response.status_code == 403
assert not target.exists()
def test_server_mode_admin_key_cannot_supply_raw_models_path(env, monkeypatch, tmp_path):
"""An admin key is not a native path authorization."""
from fastapi.testclient import TestClient
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret")
target = tmp_path / "must-not-exist"
app = fastapi.FastAPI()
app.include_router(s.router)
response = TestClient(app, client=("172.17.0.1", 50000)).put(
"/api/settings/storage/models-dir",
headers={"authorization": "Bearer s3cret"},
json={"path": str(target)},
)
assert response.status_code == 422
assert not target.exists()
def test_loopback_raw_path_is_not_a_models_directory_authorization(env, tmp_path):
from fastapi.testclient import TestClient
target = tmp_path / "must-not-exist"
app = fastapi.FastAPI()
app.include_router(s.router)
response = TestClient(app, client=("127.0.0.1", 50000)).put(
"/api/settings/storage/models-dir", json={"path": str(target)}
)
assert response.status_code == 422
assert not target.exists()
def test_models_directory_authorization_is_one_shot(env, tmp_path):
body = _body(str(tmp_path / "models"))
assert s.set_models_dir(body)["configured"]
with pytest.raises(fastapi.HTTPException) as exc:
s.set_models_dir(body)
assert exc.value.status_code == 403
def test_clear_reverts_to_default(env):
user_env.set_user_env("OMNIVOICE_CACHE_DIR", "/old")
res = s.set_models_dir(s._ModelsDirBody(path=""))
res = s.set_models_dir(_body(""))
assert res["configured"] is None
assert res["restart_required"] is True
assert user_env.get_user_env("OMNIVOICE_CACHE_DIR") is None
@@ -84,7 +154,7 @@ def test_path_with_spaces_survives_the_full_persistence_chain(env, tmp_path, mon
still shows the chosen folder. Pin the whole chain byte-for-byte:
endpoint env file load_into_environ os.environ GET."""
target = str(tmp_path / "Program Data" / "OmniVoice" / "Model Cache")
res = s.set_models_dir(s._ModelsDirBody(path=target))
res = s.set_models_dir(_body(target))
abs_target = os.path.abspath(target)
assert res["configured"] == abs_target
assert user_env.get_user_env("OMNIVOICE_CACHE_DIR") == abs_target
+29
View File
@@ -75,6 +75,35 @@ def test_network_state_endpoint_defaults_disabled():
assert r.json()["enabled"] is False
def test_pin_only_remote_discovery_never_returns_share_pin(monkeypatch):
import importlib
from main import app
# Resolve the exact module instance held by the live router. The full suite
# deliberately replaces app modules in sys.modules, so the module-level
# ``ns`` test helper may no longer be the endpoint's dependency.
live_network_share = importlib.import_module("api.routers.system").network_share
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1")
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
monkeypatch.setattr(
live_network_share,
"_state",
live_network_share.ShareState(True, 3901, "123456", ["192.168.1.10"]),
)
# Keep the consumption middleware inert: this endpoint is testing the
# intentional admin read-only exception itself, before a PIN is supplied.
monkeypatch.setattr(app.state, "network_share", None, raising=False)
response = TestClient(app, client=("172.17.0.1", 50000)).get(
"/system/network/state"
)
assert response.status_code == 200
assert response.json()["pin"] is None
assert response.json()["pin_required"] is True
assert "123456" not in response.text
def test_network_control_rejects_non_loopback():
from main import app
c = TestClient(app, client=("10.0.0.5", 9999))
+286
View File
@@ -0,0 +1,286 @@
"""Resolving an engine's venv is work, and work must report progress (#1414).
`SubprocessBackend._spawn()` calls `venv_python()`, and on a cold first run
that is not cheap: the probe spawns each candidate interpreter to import the
engine (tens of seconds on a slow disk), and if none is installed it can run
the whole `uv venv` + `uv pip install` bootstrap which is bounded at 900 s
*by design*, because installing torch takes minutes.
All of that happens on a GPU-pool worker, inside a generate request whose
execution budget defaults to 300 s. Nothing along the way reported progress,
so the budget expired part-way through and the job was abandoned and, until
#1424, blamed on the machine's compute. The first generation that triggers a
bootstrap could therefore never succeed, no matter how good the hardware.
The sidecar's own cold model load already heartbeats for exactly this reason
(#1367). Resolution is the step immediately before it that never did.
"""
from __future__ import annotations
import threading
from contextlib import contextmanager
from pathlib import Path
import pytest
@pytest.fixture
def sb():
"""Resolved at run time, not import time."""
import services.subprocess_backend as _sb
return _sb
@pytest.fixture
def mm():
import services.model_manager as _mm
return _mm
def test_a_slow_resolution_extends_the_deadline(sb, mm, monkeypatch):
"""The whole point: a resolution that outlives one heartbeat interval
leaves proof of life on the execution clock."""
monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0)
monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: True)
ident = threading.get_ident()
mm._MODEL_LOAD_ACTIVITY.pop(ident, None)
wrote = threading.Event()
class _SignallingMap(dict):
def __setitem__(self, key, value):
super().__setitem__(key, value)
wrote.set()
monkeypatch.setattr(mm, "_MODEL_LOAD_ACTIVITY", _SignallingMap())
with sb._heartbeat_while_resolving("indextts2"):
assert wrote.wait(2), "the heartbeat thread never reported progress"
assert ident in mm._MODEL_LOAD_ACTIVITY, (
"a slow venv resolution reported no progress — the generate budget "
"expires part-way through the install it is waiting for"
)
mm._MODEL_LOAD_ACTIVITY.pop(ident, None)
def test_it_credits_the_resolving_thread_not_the_beater(sb, mm, monkeypatch):
"""The heartbeat runs on a helper thread so it can tick while resolution
blocks but the job the clock is watching is the caller's. Crediting the
helper's ident would extend nothing and would poison an ident a pool
worker may later reuse.
Asserted against the beater's OWN ident rather than a before/after diff of
the activity map: other tests in the suite have live pool workers, so the
diff is not this test's to own.
"""
monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0)
monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: True)
ident = threading.get_ident()
mm._MODEL_LOAD_ACTIVITY.pop(ident, None)
beater_idents: set[int] = set()
wrote = threading.Event()
class _SignallingMap(dict):
def __setitem__(self, key, value):
super().__setitem__(key, value)
wrote.set()
monkeypatch.setattr(mm, "_MODEL_LOAD_ACTIVITY", _SignallingMap())
real_thread = threading.Thread
class _Recording(real_thread):
def run(self):
beater_idents.add(threading.get_ident())
super().run()
monkeypatch.setattr(sb.threading, "Thread", _Recording)
with sb._heartbeat_while_resolving("indextts2"):
assert wrote.wait(2), "the heartbeat thread never reported progress"
assert ident in mm._MODEL_LOAD_ACTIVITY, "the caller's job was never credited"
assert beater_idents, "no heartbeat thread ran"
assert not (beater_idents & set(mm._MODEL_LOAD_ACTIVITY)), (
"the heartbeat thread credited its own ident — that extends nothing "
"and poisons an ident a pool worker may later reuse"
)
mm._MODEL_LOAD_ACTIVITY.pop(ident, None)
def test_an_off_pool_caller_never_heartbeats(sb, mm, monkeypatch):
"""#1379's lesson: an off-pool thread's ident is not tracked by the clock,
and a pool worker that later reuses it would inherit unearned extension."""
monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: False)
ident = threading.get_ident()
mm._MODEL_LOAD_ACTIVITY.pop(ident, None)
class _UnexpectedThread:
def __init__(self, *args, **kwargs):
raise AssertionError("an off-pool call started a heartbeat helper")
monkeypatch.setattr(sb.threading, "Thread", _UnexpectedThread)
with sb._heartbeat_while_resolving("indextts2"):
pass
assert ident not in mm._MODEL_LOAD_ACTIVITY
def test_the_beater_stops_when_resolution_finishes(sb, mm, monkeypatch):
"""A thread per spawn that never exits would accumulate one per generate."""
monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0)
monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: True)
exited = threading.Event()
real_thread = threading.Thread
class _Recording(real_thread):
def run(self):
try:
super().run()
finally:
exited.set()
monkeypatch.setattr(sb.threading, "Thread", _Recording)
with sb._heartbeat_while_resolving("indextts2"):
pass
assert exited.wait(2), "heartbeat helper survived context exit"
mm._MODEL_LOAD_ACTIVITY.pop(threading.get_ident(), None)
def test_a_broken_heartbeat_does_not_break_the_spawn(sb, monkeypatch):
"""Never raises: a generation must not fail because progress reporting
could not import or could not write."""
monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0.01)
import services.model_manager as mm
monkeypatch.setattr(
mm, "running_on_gpu_pool",
lambda: (_ for _ in ()).throw(RuntimeError("clock is broken")),
)
with sb._heartbeat_while_resolving("indextts2"):
pass # the point is that this block is reached and exits cleanly
def test_spawn_wraps_the_resolution(sb, monkeypatch):
"""A guard against the wrapper being dropped in a later refactor: the
heartbeat is worthless if `venv_python()` is called outside it."""
active = False
resolved_inside = threading.Event()
@contextmanager
def _recording_heartbeat(_engine_id):
nonlocal active
active = True
try:
yield
finally:
active = False
class _StopAfterResolution(RuntimeError):
pass
class _Backend(sb.SubprocessBackend):
id = "test"
@property
def sample_rate(self):
return 24_000
@property
def supported_languages(self):
return ["en"]
@classmethod
def is_available(cls):
return True, "ready"
@classmethod
def venv_python(cls):
assert active, "venv_python() ran outside the heartbeat context"
resolved_inside.set()
return Path("python")
@classmethod
def sidecar_script(cls):
raise _StopAfterResolution
monkeypatch.setattr(sb, "_heartbeat_while_resolving", _recording_heartbeat)
backend = _Backend.__new__(_Backend)
backend._proc = None
with pytest.raises(_StopAfterResolution):
backend._spawn()
assert resolved_inside.is_set()
def test_no_heartbeat_write_escapes_the_context(sb, mm, monkeypatch):
"""The late-write race (CodeRabbit, #1426).
`_beat()` can be past its `stop.wait()` and already committed to a write
at the moment the context exits. Signalling the stop flag without joining
lets that write land afterwards and `_run_on_gpu_pool`'s `_job` pops
this ident right after, precisely so a stale beat cannot vouch for a later
job on the same (reused) worker ident. A write that arrives after the pop
resurrects the entry, and the next job inherits a heartbeat it never sent:
the wedge detector reads it as progress and keeps extending a stuck job.
The interleaving is forced rather than waited for. A patched writer parks
inside the write until the test releases it, so the exit path must be the
thing that waits if it only signals, the write lands after the context
and the assertion catches it deterministically, on every run and every
scheduler.
"""
monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0)
monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: True)
ident = threading.get_ident()
mm._MODEL_LOAD_ACTIVITY.pop(ident, None)
in_write = threading.Event()
release = threading.Event()
write_finished = threading.Event()
class _ParkingMap(dict):
"""Stalls the heartbeat mid-write so the exit path has to wait."""
def __setitem__(self, key, value):
in_write.set()
assert release.wait(5), "test did not release the parked write"
super().__setitem__(key, value)
write_finished.set()
context_exited = threading.Event()
monkeypatch.setattr(mm, "_MODEL_LOAD_ACTIVITY", _ParkingMap())
joined = threading.Event()
real_thread = threading.Thread
class _JoinRecordingThread(real_thread):
def join(self, *args, **kwargs):
joined.set()
return super().join(*args, **kwargs)
monkeypatch.setattr(sb.threading, "Thread", _JoinRecordingThread)
def _run_context():
with sb._heartbeat_while_resolving("indextts2"):
assert in_write.wait(5), "heartbeat never attempted a write"
context_exited.set()
runner = real_thread(target=_run_context)
runner.start()
try:
assert in_write.wait(5), "heartbeat never reached the parked write"
assert joined.wait(2), "context exit did not wait for the heartbeat helper"
assert not context_exited.is_set(), "context exited before the write finished"
finally:
release.set()
runner.join(5)
assert write_finished.is_set(), "parked heartbeat write did not finish"
assert not runner.is_alive(), "resolve context did not exit after the write"
assert context_exited.is_set()
mm._MODEL_LOAD_ACTIVITY.pop(ident, None)
+23
View File
@@ -7,6 +7,8 @@ strip poison ("[object Object]", freeform prose) down to whitelist tags,
recovering a design from ``vd_states`` when the stored value is unusable.
"""
import json
from statistics import median
import time
import pytest
@@ -57,6 +59,27 @@ def test_sanitize_handles_empty_and_poison(bad):
assert sanitize_instruct(bad) == ""
def test_sanitize_long_whitespace_run_is_linear_time():
sizes = (2_000, 8_000, 32_000)
timings = []
for size in sizes:
poisoned = "female" + (" " * size) + "not-a-tag"
assert sanitize_instruct(poisoned) == "" # warm timer/cache paths
samples = []
for _ in range(5):
started = time.perf_counter_ns()
assert sanitize_instruct(poisoned) == ""
samples.append(time.perf_counter_ns() - started)
timings.append(median(samples))
# Input grows 4x per step and 16x overall. Generous multipliers and a
# 20 ms scheduling floor tolerate noisy shared runners while still
# separating linear split/strip behavior from the former quadratic regex.
for smaller, larger in zip(timings, timings[1:]):
assert larger <= max(smaller * 10, 20_000_000)
assert timings[-1] <= max(timings[0] * 48, 20_000_000)
def test_instruct_from_vd_states_dict_drops_auto():
vd = {"gender": "female", "age": "Auto", "pitch": "high pitch", "accent": "british accent"}
assert instruct_from_vd_states(vd) == "female, high pitch, british accent"
Generated
+102 -102
View File
@@ -70,7 +70,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.14.1"
version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -82,108 +82,108 @@ 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/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/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]]