diff --git a/README.md b/README.md index 74ead96..13fa45c 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,11 @@ Use your **WorkBuddy / CodeBuddy (Tencent)** subscription as local **OpenAI- and - Chat Completions, Responses and Anthropic Messages, with tool calling and streaming. - Built-in **WebUI** for browser login, models, credentials, logs and settings — no desktop client required. - Automatic multi-account routing across domestic and international sites, with credential refresh. +- Per-account automation: domestic check-in then Buddy travel by default, with independent switches; international check-in is opt-in. ## Quick start -Requires Git and Docker Compose. This builds from source and includes the WebUI. +Requires Git and Docker Compose. Use the prebuilt GHCR image; no local build is needed. ```bash git clone https://github.com/maiphucgiang/codebuddy2api.git @@ -18,20 +19,36 @@ cd codebuddy2api cp .env.example .env ``` -Edit `.env` and set `CODEBUDDY2API_KEY` to your own random key; do not overwrite an existing `.env`. Then start: +For first setup, edit `.env`, set `CODEBUDDY2API_KEY` to your own random key, and choose the image below. Preserve an existing `.env`: + +```dotenv +CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest +``` ```bash -docker compose build -docker compose up -d +docker compose pull +docker compose up -d --no-build ``` +`latest` tracks stable releases; pin a published version tag for reproducible deployments. Image features belong to that version, not to unmerged source branches. + 1. Open **http://127.0.0.1:8787/dashboard** and sign in with that API key. 2. In **Credentials**, add a domestic or international account through browser login, or import an `.info` file. 3. In **Models**, find an available model and use its public ID in your client. The template binds to localhost only. Configure HTTPS and restrict network access before allowing remote connections; keep and securely back up the `auth/` data directory. -[Published images and local Python setup →](docs/deployment.md) +### Run current source locally + +With Python 3.12+, uv, Node.js and the vp CLI installed, prepare `.env` as above: + +```bash +uv sync --locked --no-build --python 3.12 +(cd web && vp install --frozen-lockfile && vp build) +uv run --locked --no-build --env-file .env converter.py --desensitize +``` + +[Source image builds, CLI login and deployment details →](docs/deployment.md) ## Client setup diff --git a/README.zh-CN.md b/README.zh-CN.md index e8e00e8..b3966f4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -7,10 +7,11 @@ - 支持 Chat Completions、Responses 和 Anthropic Messages,包含工具调用与流式输出。 - 内置 **WebUI**:扫码添加账号,管理模型、凭证、日志与设置,无需桌面端。 - 多账号自动选路,兼容国内/国际站,自动刷新凭证。 +- 按账号配置自动任务:国内默认签到后派 Buddy 旅行,可分别关闭;国际自动签到默认关闭。 ## 快速开始 -需要 Git 和 Docker Compose;以下方式从源码构建,已包含 WebUI。 +需要 Git 和 Docker Compose;直接拉取 GHCR 已构建镜像,无需在本机编译。 ```bash git clone https://github.com/maiphucgiang/codebuddy2api.git @@ -18,20 +19,36 @@ cd codebuddy2api cp .env.example .env ``` -编辑 `.env`,将 `CODEBUDDY2API_KEY` 设置为你自己的随机密钥;已有 `.env` 请勿覆盖。然后启动: +首次部署时编辑 `.env`,将 `CODEBUDDY2API_KEY` 设置为自己的随机密钥,并指定镜像;已有 `.env` 请保留: + +```dotenv +CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebuddy2api:latest +``` ```bash -docker compose build -docker compose up -d +docker compose pull +docker compose up -d --no-build ``` +`latest` 跟随稳定发行版;需固定部署时改用已发布的版本标签。镜像功能以对应版本为准,不包含尚未合并的源码分支改动。 + 1. 打开 **http://127.0.0.1:8787/dashboard**,使用刚设置的 API key 登录。 2. 在「凭证管理」扫码添加国内或国际账号,也可导入 `.info` 文件。 3. 在「模型路由」查看可用模型,将其对外 ID 填入客户端。 按模板配置时仅允许本机访问。远程访问前请配置 HTTPS 并限制网络访问;保留并妥善备份 `auth/` 数据目录。 -[使用发布镜像或本地 Python 运行 →](docs/deployment.zh-CN.md) +### 本地运行当前源码 + +安装 Python 3.12+、uv、Node.js 与 vp CLI,并按上面配置 `.env`: + +```bash +uv sync --locked --no-build --python 3.12 +(cd web && vp install --frozen-lockfile && vp build) +uv run --locked --no-build --env-file .env converter.py --desensitize +``` + +[开发用镜像构建、命令行登录与部署详情 →](docs/deployment.zh-CN.md) ## 客户端接入 diff --git a/app/admin_api.py b/app/admin_api.py index 1910e45..ee68e84 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -8,6 +8,7 @@ import threading import time from urllib.parse import quote, urlsplit +import uuid import zipfile from fastapi import HTTPException, Request @@ -46,6 +47,7 @@ def _public_credential(item): "credits_by_profile", "catalog", "catalog_sync", "sync", "generation", "auth_broken", "models", "remaining", "enterprise_id", "product", "status", "sync_pending", "sync_error", "fail_until", "cooldown_until", "cooldown_remaining", "last_failure_at", "catalog_ready", "bindings", + "auto_checkin", "auto_travel", "travel_supported", "checkin", "travel", "token_expired", "token_expires_at", "last_refresh_time", "sessions", "sticky_sessions", "last_error_code"} result = {key: value for key, value in item.items() if key in fields} identity = item.get("account_key") or item.get("id") @@ -250,18 +252,29 @@ def apply(): @route("GET", "/admin/models") async def models_get(request): - snapshot = control.snapshot() - models = [] - for item in await run_in_threadpool(gateway.admin_model_inventory): - item = {"id": item} if isinstance(item, str) else dict(item) - source = item["id"] - rule = snapshot["models"].get(source, {"public_id": source, "enabled": True, "keep_original": False, - "region": None, "profile": None, "credential_ids": []}) - models.append({**item, **rule}) - return JSONResponse({"revision": snapshot["revision"], "models": models}) - - def checked_rule(source, data): - rule = validate_model(source, data, control.snapshot()["models"], known_models()) + def build_models(): + with mutation_lock: + inventory = gateway.admin_model_inventory() + snapshot = control.snapshot() # 扫描可能同步账号身份,随后读取对应的规则版本。 + models = [] + for item in inventory: + item = {"id": item} if isinstance(item, str) else dict(item) + source = item["id"] + rule = snapshot["models"].get(source, {"public_id": source, "enabled": True, "keep_original": False, + "region": None, "profile": None, "credential_ids": []}) + models.append({**item, **rule}) + return JSONResponse({"revision": snapshot["revision"], "models": models}) + return await run_in_threadpool(build_models) + + def checked_rule(source, data, *, creating=False): + if "custom" in data: + raise ValueError("custom 是只读字段") + existing = control.snapshot()["models"].get(source, {}) + if creating and (not data.get("public_id") or not data.get("upstream_id")): + raise ValueError("对外 ID 和上游模型 ID 均不能为空") + values = {"upstream_id": existing.get("upstream_id", source), **data, + "custom": True if creating else existing.get("custom", False)} + rule = validate_model(source, values, control.snapshot()["models"], known_models()) for identity in rule["credential_ids"]: item = selected(identity) if item is None: @@ -272,6 +285,42 @@ def checked_rule(source, data): raise ValueError("绑定凭证与区域或产品规则冲突") return rule + @route("POST", "/admin/models") + async def models_create(request): + data = await _body(request) + revision = data.pop("revision", None) + source = "custom:" + uuid.uuid4().hex + def apply(): + with mutation_lock: + rule = checked_rule(source, data, creating=True) + snapshot = control.update_model(source, rule, revision, known_models()) + event("model.created", {"model": rule["public_id"]}) + return JSONResponse({"revision": snapshot["revision"], "model": {"id": source, **rule}}, status_code=201) + return await run_in_threadpool(apply) + + @route("POST", "/admin/models/preview") + async def models_create_preview(request): + data = await _body(request) + data.pop("revision", None) + source = "custom:" + uuid.uuid4().hex + def preview(): + return JSONResponse(gateway.admin_model_preview(source, checked_rule(source, data, creating=True))) + return await run_in_threadpool(preview) + + @route("DELETE", "/admin/models/{id:path}") + async def models_delete(request): + data = await _body(request) + if set(data) != {"revision"}: + raise ValueError("删除模型只接受 revision") + source = request.path_params["id"] + def remove(): + with mutation_lock: + snapshot = control.delete_model(source, data["revision"]) + event("model.deleted", {"model": source}) + return JSONResponse({"revision": snapshot["revision"], "ok": True}) + return await run_in_threadpool(remove) + + @route("PUT", "/admin/models/{id:path}") async def models_put(request): data = await _body(request) @@ -297,17 +346,23 @@ def preview(): @route("PATCH", "/admin/credentials/{id}") async def credentials_patch(request): data = await _body(request) - if set(data) != {"enabled"} or type(data["enabled"]) is not bool: - raise ValueError("enabled 必须为布尔值") + if set(data) not in ({"enabled"}, {"auto_checkin"}, {"auto_travel"}) or any(type(value) is not bool for value in data.values()): + raise ValueError("仅接受一个布尔字段:enabled、auto_checkin 或 auto_travel") + field, value = next(iter(data.items())) identity = request.path_params["id"] def apply(): if selected(identity) is None: return error_response(404, "凭证不存在") with mutation_lock: # The gateway persists under the pool lock before publishing routing state. - gateway.admin_set_credential_enabled(identity, data["enabled"]) - event("credential.enabled", {"credential": identity, "enabled": data["enabled"]}) - return JSONResponse({"id": identity, "enabled": data["enabled"], "revision": control.snapshot()["revision"]}) + if field == "enabled": + gateway.admin_set_credential_enabled(identity, value) + elif field == "auto_checkin": + gateway.admin_set_auto_checkin(identity, value) + else: + gateway.admin_set_auto_travel(identity, value) + event("credential." + field, {"credential": identity, field: value}) + return JSONResponse({"id": identity, field: value, "revision": control.snapshot()["revision"]}) return await run_in_threadpool(apply) def upload(body): @@ -442,8 +497,11 @@ async def dashboard_get(request): raise ValueError("days 必须为 1、7、30 或 90") from None if days not in (1, 7, 30, 90): raise ValueError("days 必须为 1、7、30 或 90") + granularity = request.query_params.get("granularity", "auto") + if granularity not in ("auto", "hour", "day"): + raise ValueError("granularity 必须为 auto、hour 或 day") def build_dashboard(): - result = audit.dashboard(days) + result = audit.dashboard(days, granularity=granularity) if result.get("degraded"): return error_response(503, "统计暂时无法读取,不能确认当前数值;请检查审计存储状态") rows = [_public_credential(item) for item in inventory()] diff --git a/app/audit_store.py b/app/audit_store.py index 9cff907..5ea9c77 100644 --- a/app/audit_store.py +++ b/app/audit_store.py @@ -475,12 +475,16 @@ def fetch(): return json.loads(row[0]) if row else None return self._run(fetch, write=True) - def dashboard(self, days=30): + def dashboard(self, days=30, granularity="auto"): days = int(days) - if not 1 <= days <= 36500: - raise ValueError("invalid days") + if not 1 <= days <= 36500 or granularity not in ("auto", "hour", "day"): + raise ValueError("invalid dashboard range or granularity") + grain = ("hour" if days == 1 else "day") if granularity == "auto" else granularity + if grain == "hour" and days > 90: + raise ValueError("hourly range exceeds 90 days") now = time.time() start = int(now // 86400) * 86400 - (days - 1) * 86400 + period = {"days": days, "start": start, "end": now, "timezone": "UTC", "granularity": grain} def fetch(): summary = self._empty_stats() series, models, profiles = [], {}, {} @@ -489,17 +493,28 @@ def fetch(): stats = json.loads(row["payload"]) if row["dimension"] == "global": self._merge(summary, stats) - series.append({"bucket": row["bucket"], "date": time.strftime("%Y-%m-%d", time.gmtime(row["bucket"])), **stats}) + if grain == "day": + series.append({"bucket": row["bucket"], **stats}) elif row["dimension"] in ("model", "profile"): target = models if row["dimension"] == "model" else profiles self._merge(target.setdefault(row["dimension_key"], self._empty_stats()), stats) + if grain == "hour": + rows = self._db.execute("SELECT bucket,payload FROM stats_hourly WHERE dimension='global' AND bucket>=? AND bucket<=? ORDER BY bucket", (start, now)).fetchall() + series = [{"bucket": row["bucket"], **json.loads(row["payload"])} for row in rows] + partial = grain == "hour" and sum(row["requests"] for row in series) != summary["requests"] + step = 3600 if grain == "hour" else 86400 + if days <= 90 and not partial: + recorded = {row["bucket"]: row for row in series} + series = [recorded.get(bucket, {"bucket": bucket, **self._empty_stats()}) + for bucket in range(start, int(now // step) * step + 1, step)] + for row in series: + row["date"] = time.strftime("%Y-%m-%d %H:00" if grain == "hour" else "%Y-%m-%d", time.gmtime(row["bucket"])) summary["success_rate"] = summary["success"] / summary["requests"] if summary["requests"] else None return {"summary": summary, "series": series, "models": [{"model": key, **value} for key, value in models.items()], "profiles": [{"profile": key, **value} for key, value in profiles.items()], - "generated_at": now, "range": {"days": days, "start": start, "end": now, "timezone": "UTC"}} - return self._run(fetch, {"summary": self._empty_stats(), "series": [], "models": [], "profiles": [], "generated_at": now, "range": {"days": days, "start": start, "end": now}, "degraded": True}) - + "generated_at": now, "range": {**period, "partial": partial}} + return self._run(fetch, {"summary": self._empty_stats(), "series": [], "models": [], "profiles": [], "generated_at": now, "range": period, "degraded": True}) def storage(self): def fetch(): self._prune() diff --git a/app/checkin.py b/app/checkin.py new file mode 100644 index 0000000..a8987ca --- /dev/null +++ b/app/checkin.py @@ -0,0 +1,48 @@ +"""Activity-gated check-in with safe status labels shared by manual and scheduled work.""" +from . import credits + +MESSAGES = { + "success": "签到成功,余额可另行同步", + "already": "今日已签到", + "inactive": "签到活动未开放或已结束", + "not_eligible": "当前账号不具备签到资格", + "unknown": "活动状态未确认,未尝试领取", + "error": "签到未确认成功,请核验账号状态", + "cancelled": "自动签到设置或凭证已变化,已取消未发送的领取", + "changed": "凭证已变化,结果未写入,请刷新核验", +} + + +def normalize(result): + state = result.get("state") + if state not in MESSAGES: + state = "already" if result.get("already") else "success" if result.get("ok") is True else "error" + return {"state": state, "ok": state in {"success", "already"}, "already": state == "already", + "skipped": state in {"inactive", "not_eligible", "cancelled"}, + "code": result.get("code"), "message": MESSAGES[state]} + + +def perform(access_token, uid="", domain="", *, can_claim=lambda: True): + if not can_claim(): + return normalize({"state": "cancelled"}) + status = credits.fetch_checkin_status(access_token, uid=uid, domain=domain) + if status.get("state") != "available": + return normalize(status) + # A setting change, account disable or new credential generation during the query cancels the claim. + if not can_claim(): + return normalize({"state": "cancelled"}) + return normalize(credits.daily_checkin(access_token, uid=uid, domain=domain)) + + +def view(record): + if not record: + return {"state": "unknown", "message": "尚未查询签到活动"} + if record.get("state") in MESSAGES: + result = normalize(record) + elif record.get("ok") is True: + result = normalize({"state": "success"}) + else: + result = normalize(credits.classify_checkin_result(False, record.get("code"), record.get("message", ""))) + if result["ok"]: + result = normalize({"state": "unknown"}) + return {"state": result["state"], "message": result["message"], "date": record.get("date"), "at": record.get("at")} diff --git a/app/control_store.py b/app/control_store.py index b3be8cf..fd94b82 100644 --- a/app/control_store.py +++ b/app/control_store.py @@ -21,13 +21,19 @@ def _identifier(value, label): return value -def validate_model(source, rule, models=None, known_models=()): - _identifier(source, "上游模型 ID") - if not isinstance(rule, dict) or set(rule) - {"public_id", "enabled", "keep_original", "region", "profile", "credential_ids"}: +def validate_model(source, rule, models=None, known_models=(), *, legacy_scopes=False): + _identifier(source, "模型规则 ID") + if not isinstance(rule, dict) or set(rule) - {"public_id", "upstream_id", "custom", "enabled", "keep_original", "region", "profile", "credential_ids"}: raise ValueError("模型规则字段无效") - clean = {"public_id": source, "enabled": True, "keep_original": False, + clean = {"public_id": source, "upstream_id": source, "custom": False, + "enabled": True, "keep_original": False, "region": None, "profile": None, "credential_ids": [], **rule} + _identifier(clean["upstream_id"], "上游模型 ID") + if type(clean["custom"]) is not bool or (clean["custom"] and clean["keep_original"]): + raise ValueError("自建模型不能公开内部规则标识") _identifier(clean["public_id"], "公开模型 ID") + if clean["custom"] and clean["public_id"] == source: + raise ValueError("自建模型必须使用独立的对外 ID") if type(clean["enabled"]) is not bool or type(clean["keep_original"]) is not bool: raise ValueError("enabled/keep_original 必须为布尔值") if clean["region"] not in (None, "cn", "intl") or clean["profile"] not in (None, "cn-cli", "cn-work", "intl-cli", "intl-work"): @@ -41,6 +47,8 @@ def validate_model(source, rule, models=None, known_models=()): _identifier(identity, "账号指纹") if len(set(ids)) != len(ids): raise ValueError("账号指纹重复") + if ids and (clean["region"] or clean["profile"]) and not legacy_scopes: + raise ValueError("指定账号与区域/产品范围只能选择一种") all_rules = {**(models or {}), source: clean} real_ids = set(known_models) | set(all_rules) | {"auto"} aliases = {} @@ -94,10 +102,12 @@ def _load(self): if not isinstance(data["models"], dict) or not isinstance(data["credentials"], dict): raise ValueError("管理数据库策略无效") for source, rule in data["models"].items(): - validate_model(source, rule, data["models"]) + validate_model(source, rule, data["models"], legacy_scopes=True) # 旧联合范围保持原有交集,编辑时再显式转换。 for identity, metadata in data["credentials"].items(): _identifier(identity, "账号指纹") - if not isinstance(metadata, dict) or set(metadata) - {"enabled", "label"} or type(metadata.get("enabled")) is not bool: + if (not isinstance(metadata, dict) or set(metadata) - {"enabled", "label", "auto_checkin", "auto_travel"} + or type(metadata.get("enabled")) is not bool + or any(key in metadata and type(metadata[key]) is not bool for key in ("auto_checkin", "auto_travel"))): raise ValueError("管理数据库凭证元数据无效") return {"revision": row[0], **data} @@ -138,12 +148,36 @@ def change(state): state["models"][source] = validate_model(source, rule, state["models"], known_models) return self._update(revision, change) + def delete_model(self, source, revision): + if type(revision) is not int: + raise ValueError("revision 必须为整数") + def change(state): + if not state["models"].get(source, {}).get("custom"): + raise ValueError("只能删除自建模型,目录模型请停用") + del state["models"][source] + return self._update(revision, change) + + def set_credential(self, account_key, enabled): _identifier(account_key, "账号指纹") if type(enabled) is not bool: raise ValueError("enabled 必须为布尔值") return self._update(None, lambda state: state["credentials"].setdefault(account_key, {}).update(enabled=enabled)) + def set_auto_checkin(self, account_key, enabled): + _identifier(account_key, "账号指纹") + if type(enabled) is not bool: + raise ValueError("auto_checkin 必须为布尔值") + return self._update(None, lambda state: state["credentials"].setdefault( + account_key, {"enabled": True}).update(auto_checkin=enabled)) + + def set_auto_travel(self, account_key, enabled): + _identifier(account_key, "账号指纹") + if type(enabled) is not bool: + raise ValueError("auto_travel 必须为布尔值") + return self._update(None, lambda state: state["credentials"].setdefault( + account_key, {"enabled": True}).update(auto_travel=enabled)) + def close(self): with self._lock: self._db.close() diff --git a/app/credential_actions.py b/app/credential_actions.py new file mode 100644 index 0000000..566475a --- /dev/null +++ b/app/credential_actions.py @@ -0,0 +1,123 @@ +"""Scoped manual credential maintenance without implicit check-in or trial claims.""" +import time +from pathlib import Path + +from fastapi import HTTPException + +from . import checkin, model_policy, travel +from .credential_io import credential_file_lock + + +def run(gateway, action, identity=None): + if action not in {"refresh", "checkin", "sync", "travel", "travel-status"} or (action in {"refresh", "travel", "travel-status"} and identity is None): + raise HTTPException(404, "凭证操作不存在") + config = gateway.CONFIG + pool, ledger = config.get("cred_pool"), config.get("ledger") + if pool is None or (action != "refresh" and (ledger is None or gateway.credits_mod is None)): + raise HTTPException(503, "凭证维护尚未就绪") + # Do not queue duplicate manual work behind the periodic maintenance sweep. + if not gateway._HOUSEKEEP_LOCK.acquire(blocking=False): + raise HTTPException(409, "凭证维护正在执行,请稍后刷新列表核验") + try: + pool._rescan() + entries = [dict(e) for e in pool.entries() if identity is None or e.get("account_key") == identity] + if identity is not None and not entries: + raise HTTPException(404, "凭证不存在或身份已变化") + results = [] + for entry in entries: + result = {"id": entry.get("account_key"), "name": Path(entry["id"]).name, "action": action, "ok": False} + if not model_policy.credential_enabled(config, entry): + result.update(skipped=True, message="账号已人工停用") + else: + try: + result.update(_one(gateway, pool, ledger, entry, action)) + if (action == "checkin" and result.get("state") not in {"changed", "cancelled"} + and model_policy.credential_auto_travel(config, entry)): + followup = _one(gateway, pool, ledger, entry, "travel", automatic=True) + result.update(travel=followup, checkin_ok=result["ok"], ok=result["ok"] and followup["ok"], + message=result["message"] + ";" + followup["message"]) + except Exception: + # Upstream exception text may contain headers or credential file paths. + if action == "checkin" and result["ok"]: + result["checkin_ok"] = True + result.update(ok=False, message="操作失败,保留已有数据;请检查账号状态后重试") + results.append(result) + audit = config.get("audit_store") + if audit: + try: + audit.event("admin", "credential." + action, {"credential": result["id"], "ok": result["ok"]}) + except Exception: + pass + response = {"ok": bool(results) and all(r["ok"] for r in results), "results": results} + if identity is None and ledger is not None: + response["credits"] = ledger.snapshot() # Keep the legacy check-in response field. + return response + finally: + gateway._HOUSEKEEP_LOCK.release() + + +def _one(gateway, pool, ledger, entry, action, *, automatic=False): + cm, cid = entry["cm"], entry["id"] + if not model_policy.credential_enabled(gateway.CONFIG, entry): + return {"ok": False, "skipped": True, "message": "账号已人工停用"} + if action in {"travel", "travel-status"} and not travel.supported(entry.get("profile")): + return travel.unavailable() + with cm._lock: + if cm.summary().get("account_key") != entry.get("account_key"): + return {"ok": False, "state": "changed", "message": "凭证身份已变化,请刷新列表"} + if action == "refresh": + with credential_file_lock(cm.path.parent, cm.path.name): + if cm.summary().get("account_key") != entry.get("account_key"): + return {"ok": False, "message": "凭证身份已变化,请刷新列表"} + cm._refresh_locked() + generation = cm._generation + else: + headers = cm.get_headers() + generation = cm._generation + if action == "refresh": + pool.reload([cm.path], reset=False) + current = pool.apply_if_current(cm, generation, lambda: None) + return {"ok": current, "message": "凭证已刷新" if current else "凭证已变化,请刷新列表核验"} + if action in {"travel", "travel-status"}: + def can_write(): + return ((not automatic or model_policy.credential_auto_travel(gateway.CONFIG, entry)) + and pool.apply_if_current(cm, generation, lambda: None)) + result = travel.perform(gateway._bearer_token(headers), gateway.profile_for_headers(headers), + read_only=action == "travel-status", can_write=can_write) + if not pool.apply_if_current(cm, generation, lambda: travel.remember(ledger, cid, result)): + return {"ok": False, "message": "凭证已变化,旅行结果未写入,请刷新核验"} + return result + if action == "checkin": + day = time.strftime("%Y-%m-%d") + if ledger.checkin_done(cid, day): + current = pool.apply_if_current(cm, generation, lambda: None) + return {"ok": current, "already": current, "state": "already" if current else "changed", + "message": "今日已签到" if current else "凭证已变化,请刷新列表核验"} + result = checkin.perform(gateway._bearer_token(headers), + uid=headers.get("X-User-Id", ""), domain=headers.get("X-Domain", ""), + can_claim=lambda: pool.apply_if_current(cm, generation, lambda: None)) + current = pool.apply_if_current(cm, generation, lambda: ledger.mark_checkin( + cid, day, result["ok"], result.get("code"), result["message"], state=result["state"])) + if not current: + return checkin.normalize({"state": "changed"}) + return result + failed = set() + ref = gateway._sync_credits(pool, ledger, entry, checkin=False, claim_trial=False, failed=failed, + expected_identity=entry.get("account_key")) + if ref is not None: + if not pool.apply_if_current(cm, ref[1], lambda: entry.update(catalog_dirty=True)): + failed.add(cid) + else: + gateway._sync_model_catalogs(pool, ledger, {cid: ref}, failed) + failed.update(gateway._sync_usage(pool, entries=[entry], expected_identity=entry.get("account_key"))) + partial = bool((ledger.entry(cid).get("credits") or {}).get("partial")) or bool( + (gateway.CONFIG.get("usage_daily_accounts") or {}).get(cid, {}).get("partial")) + ok = ref is not None and cid not in failed and not partial + if ok: + def complete(): + pool._sync_pending.discard(cid) + pool.end_sync({cid}) + if not pool._sync_pending: + pool._sync_event.clear() + ok = pool.apply_if_current(cm, ref[1], complete) + return {"ok": ok, "partial": partial, "message": "余额、目录和用量已同步" if ok else "同步不完整,保留上次成功数据"} diff --git a/app/credits.py b/app/credits.py index 2c12f0b..140015d 100644 --- a/app/credits.py +++ b/app/credits.py @@ -33,7 +33,8 @@ "intl-cli": "https://www.codebuddy.ai", "intl-work": "https://www.workbuddy.ai", } -CHECKIN_PATHS = ("/billing/meter/daily-checkin", "/v2/billing/meter/daily-checkin") +CHECKIN_PATHS = ("/v2/billing/meter/daily-checkin",) +CHECKIN_STATUS_PATH = "/v2/billing/meter/checkin-activity-status" RESOURCE_PATH = "/v2/billing/meter/get-user-resource" CONFIG_PATH = "/v3/config" # cbc CLI CloudProductProvider 同源:云端模型表 RESOURCE_PRODUCT_CODE = "p_tcaca" @@ -103,48 +104,66 @@ def _post_json(client: httpx.Client, url: str, headers: dict, body: dict) -> tup # --------------------------------------------------------------------------- def classify_checkin_result(http_ok: bool, code, message: str) -> dict: - """只接受明确成功:code=0;或 code=10001 且文案表明今日已签(幂等)。""" + """兼容旧签到文案与桌面业务码,不把活动未开放误判为已领取。""" try: - ncode = int(code) if code is not None and str(code).strip() != "" else None - except (TypeError, ValueError): + ncode = int(code) if type(code) in (int, str) else None + except ValueError: ncode = None text = str(message or "") - inactive = bool(_INACTIVE_RE.search(text)) - already = ncode == 10001 and not inactive and bool(_ALREADY_RE.search(text)) + inactive = ncode == 1003 or bool(_INACTIVE_RE.search(text)) + already = not inactive and (ncode == 1001 or (ncode == 10001 and bool(_ALREADY_RE.search(text)))) ok = not inactive and ((ncode == 0 and http_ok) or already) - return {"ok": ok, "already": already, "inactive": inactive, "code": ncode, "message": text} + state = ("already" if already else "success" if ok else "inactive" if inactive else + "not_eligible" if ncode == 1002 else "error") + return {"ok": ok, "already": already, "inactive": inactive, "state": state, "code": ncode, "message": text} -def daily_checkin(access_token: str, uid: str = "", domain: str = "") -> dict: - """仅在同 profile host 内切换签到 path;返回 classify 结果 + status/url。""" - endpoints = [h + p for h in hosts_for_token(access_token, domain) for p in CHECKIN_PATHS] - last_err = "未知错误" - first_401: dict | None = None +def _checkin_request(access_token, uid, domain, path): + host = hosts_for_token(access_token, domain)[0] + headers = {"accept": "application/json", "content-type": "application/json", + "authorization": f"Bearer {access_token}", "x-user-id": str(uid or ""), + "x-domain": str(domain or "")} + url = host + path with httpx.Client() as client: - for url in endpoints: - host = url.split("/v2/billing")[0].split("/billing")[0] # 先剥 /v2 再剥 /billing 取 origin - try: - status, payload = _post_json(client, url, _web_headers(host, access_token, uid, domain), {}) - except AuthExpiredError: - first_401 = first_401 or {"ok": False, "already": False, "code": 401, - "message": "登录身份过期", "status": 401, "url": url} - last_err = "登录身份过期" - continue - except httpx.HTTPError as e: - last_err = str(e) - continue - message = payload.get("msg") or payload.get("message") or ("ok" if 200 <= status < 300 else f"HTTP {status}") - result = classify_checkin_result(200 <= status < 300, payload.get("code"), message) - result.update(status=status, url=url) - if result["ok"]: - return result - if 400 <= status < 500 and status != 404: - return result # 客户端错误(除 404 换 path):不再兜底 - last_err = str(message) - if first_401 is not None: - return first_401 - return {"ok": False, "already": False, "inactive": False, "code": -1, - "message": last_err, "url": endpoints[0] if endpoints else None} + try: + status, payload = _post_json(client, url, headers, {}) + except AuthExpiredError: + return 401, {"code": 401}, url + except httpx.HTTPError: + return 0, {"code": -1}, url + return status, payload if isinstance(payload, dict) else {}, url + + +def daily_checkin(access_token: str, uid: str = "", domain: str = "") -> dict: + """仅调用同 profile 的桌面 Bearer 接口;发送后失败不重放领取。""" + status, payload, url = _checkin_request(access_token, uid, domain, CHECKIN_PATHS[0]) + message = payload.get("msg") or payload.get("message") or "" + result = classify_checkin_result(200 <= status < 300, payload.get("code"), message) + if not (200 <= status < 300 or status in (400, 409)): + result.update(ok=False, already=False, inactive=False, state="error") + result.update(status=status, url=url) + return result + + +def fetch_checkin_status(access_token: str, uid: str = "", domain: str = "") -> dict: + """查询活动状态;不完整响应不能授权领取。""" + status, payload, _ = _checkin_request(access_token, uid, domain, CHECKIN_STATUS_PATH) + result = classify_checkin_result(200 <= status < 300, payload.get("code"), + payload.get("msg") or payload.get("message") or "") + if not (200 <= status < 300 or status in (400, 409)): + return {"ok": False, "state": "unknown", "code": result["code"]} + if result["state"] in {"already", "inactive", "not_eligible"}: + return result + data = payload.get("data") + if not (200 <= status < 300 and result["code"] == 0 and isinstance(data, dict) + and type(data.get("active")) is bool): + return {"ok": False, "state": "unknown", "code": result["code"]} + if not data["active"]: + return {"ok": False, "state": "inactive", "code": 0} + if type(data.get("today_checked_in")) is not bool: + return {"ok": False, "state": "unknown", "code": 0} + return {"ok": data["today_checked_in"], "already": data["today_checked_in"], + "state": "already" if data["today_checked_in"] else "available", "code": 0} # --------------------------------------------------------------------------- @@ -687,12 +706,19 @@ def checkin_done(self, cred_id: str, day: str) -> bool: c = self._entry(cred_id).get("checkin") or {} return c.get("date") == day and c.get("ok") is True - def mark_checkin(self, cred_id: str, day: str, ok: bool, code, message: str): + def mark_checkin(self, cred_id: str, day: str, ok: bool, code, message: str, *, state=None): with self._lock: self._entry(cred_id)["checkin"] = { "date": day, "ok": bool(ok), "code": code, "message": str(message or "")[:200], "at": time.time(), } + if state is not None: + self._entry(cred_id)["checkin"]["state"] = state + self._save() + + def update_travel(self, cred_id: str, result: dict): + with self._lock: + self._entry(cred_id)["travel"] = deepcopy(result) self._save() # ---- 积分 ---- diff --git a/app/gateway_management.py b/app/gateway_management.py index e9646cf..402fefc 100644 --- a/app/gateway_management.py +++ b/app/gateway_management.py @@ -8,7 +8,7 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from starlette.staticfiles import StaticFiles -from . import model_policy +from . import checkin, model_policy, travel class Management: @@ -43,6 +43,11 @@ def admin_credential_inventory(self): state = ("disabled" if not enabled else "error" if row.get("error") else "circuit_open" if until > now else "expired" if row.get("token_expired") else "ready") row.update(id=identity, name=Path(path).name, enabled=enabled, health=state, + auto_checkin=model_policy.credential_auto_checkin(self.CONFIG, entry), + checkin=checkin.view(balance.get("checkin") or {}), + auto_travel=model_policy.credential_auto_travel(self.CONFIG, entry), + travel_supported=travel.supported(entry.get("profile")), + travel=balance.get("travel") or {"state": "unknown", "message": "尚未查询旅行状态"}, fail_until=until, cooldown_until=until, cooldown_remaining=max(0, round(until - now)), last_error_code=("http_401" if entry.get("last_error") == "backend HTTP 401" else @@ -52,7 +57,7 @@ def admin_credential_inventory(self): cooldowns=cooldowns, credits=balance.get("credits") or None, sync_pending=path in pool._sync_pending or path in pool._syncing or path in pool._sync_retry, catalog_ready=(self.CONFIG.get("account_catalogs") or {}).get(identity, {}).get("models") is not None, - bindings=[source for source, rule in model_policy.snapshot(self.CONFIG)["models"].items() + bindings=[rule.get("public_id", source) for source, rule in model_policy.snapshot(self.CONFIG)["models"].items() if identity in rule.get("credential_ids", [])]) result.append(row) return result @@ -77,6 +82,28 @@ def admin_set_credential_enabled(self, identity, enabled): self.gateway.invalidate_model_table() return next(row for row in self.admin_credential_inventory() if row["id"] == identity) + def admin_set_auto_checkin(self, identity, enabled): + pool = self.CONFIG.get("cred_pool") + if pool is None: + raise HTTPException(404, "凭证不存在") + with pool._lock: + if not any(entry.get("account_key") == identity for entry in pool.entries()): + raise HTTPException(404, "凭证不存在") + self.CONFIG["control_store"].set_auto_checkin(identity, enabled) + # Persist only: enabling does not launch a claim or enqueue unrelated synchronization. + + def admin_set_auto_travel(self, identity, enabled): + pool = self.CONFIG.get("cred_pool") + if pool is None: + raise HTTPException(404, "凭证不存在") + with pool._lock: + entry = next((entry for entry in pool.entries() if entry.get("account_key") == identity), None) + if entry is None: + raise HTTPException(404, "凭证不存在") + if enabled and not travel.supported(entry.get("profile")): + raise HTTPException(400, "旅行仅适用于国内账号") + self.CONFIG["control_store"].set_auto_travel(identity, enabled) + def admin_delete_guard(self, name): rows = self.admin_credential_inventory() row = next((row for row in rows if row["name"] == name or row["id"] == name), None) @@ -106,11 +133,13 @@ def admin_model_inventory(self): for source, row in facts.items(): rule = model_policy.rule_for(self.CONFIG, source) preview = self.admin_model_preview(source, rule) - result.append({**row, **rule, "available_credentials": len(preview["candidates"]), + target = facts.get(rule["upstream_id"], row) + result.append({**target, **rule, "id": source, "available_credentials": len(preview["candidates"]), "available": bool(preview["candidates"])}) return sorted(result, key=lambda row: row["id"]) def admin_model_preview(self, source, rule): + upstream = rule.get("upstream_id", source) pool = self.CONFIG.get("cred_pool") accepted, rejected = [], [] if pool is None: @@ -128,20 +157,20 @@ def admin_model_preview(self, source, rule): reason = "不在绑定范围" elif not pool._healthy(entry): reason = "认证熔断" - elif not pool._model_healthy(entry, source): + elif not pool._model_healthy(entry, upstream): reason = "模型额度冷却" - else: + elif not pool._model_servable(entry, upstream): + reason = "后端模型暂时不可用" + elif not pool._eligible(entry, upstream, rule=rule): account = (self.CONFIG.get("account_catalogs") or {}).get(identity, {}) models = self.gateway._account_scope(account, "serves") - if self.CONFIG.get("account_catalogs") is not None: - if models is None: - reason = "目录尚未就绪" - elif not any(item["id"] == self.gateway._upstream_model(source, profile) - for item in self.gateway._usable_models(models)) and not ( - source == "auto" and profile == "cn-cli" and self.gateway._usable_models(models)): - reason = "账号自身目录不支持模型" - if reason is None and not (pool._has_credit(entry, profile) or pool._model_free(entry, source)): + if (self.CONFIG.get("account_catalogs") is not None or self.CONFIG.get("model_cache") is not None) and ( + models is None or account.get("profile") != profile): + reason = "目录尚未就绪" + elif not (pool._has_credit(entry, profile) or pool._model_free(entry, upstream)): reason = "额度不足或未知" + else: + reason = "账号自身目录不支持模型" item = {"id": identity, "name": Path(entry["id"]).name, "profile": profile} if reason: rejected.append({**item, "reason": reason}) diff --git a/app/model_policy.py b/app/model_policy.py index bbd4251..6d0786e 100644 --- a/app/model_policy.py +++ b/app/model_policy.py @@ -1,10 +1,8 @@ """Local model policies layered over account-owned upstream capabilities.""" - from contextvars import ContextVar from fastapi import HTTPException - _request_policy = ContextVar("gateway_model_policy", default=None) @@ -31,8 +29,20 @@ def credential_enabled(config, entry): return snapshot(config)["credentials"].get(entry.get("account_key"), {}).get("enabled", True) +def credential_auto_checkin(config, entry): + """International accounts opt in explicitly; the preference is independent of routing.""" + default = entry.get("profile") in {"cn-cli", "cn-work"} + return snapshot(config)["credentials"].get(entry.get("account_key"), {}).get("auto_checkin", default) + + +def credential_auto_travel(config, entry): + supported = entry.get("profile") in {"cn-cli", "cn-work"} + return supported and snapshot(config)["credentials"].get(entry.get("account_key"), {}).get("auto_travel", True) + + def default_rule(source): - return {"public_id": source, "enabled": True, "keep_original": False, + return {"public_id": source, "upstream_id": source, "custom": False, + "enabled": True, "keep_original": False, "region": None, "profile": None, "credential_ids": []} @@ -45,46 +55,54 @@ def _deny(message, code="model_disabled"): "message": message, "type": "invalid_request_error", "param": "model", "code": code}}) +def _active(config, upstream): + state = _request_policy.get() + if state and state.get("upstream") == upstream: + return rule_for(config, state["source"]), state + return rule_for(config, upstream), None + + def resolve(config, public_model): - """Resolve once per request, keeping the public name separate from wire data.""" + """Resolve one public name to a literal upstream ID while retaining its own policy.""" if not isinstance(public_model, str) or not public_model.strip(): raise HTTPException(status_code=400, detail={"error": {"message": "model must be a non-empty string"}}) state = _request_policy.get() - if state and state.get("source") == public_model: - source, rule = state["source"], state["rule"] - if rule_for(config, source) != rule: + if state and public_model in (state.get("upstream"), state.get("public_model")): + if rule_for(config, state["source"]) != state["rule"]: _deny("模型策略已变更,请重新请求", "model_policy_changed") - return source + return state["upstream"] data = snapshot(config) source = next((key for key, value in data["models"].items() if value.get("public_id", key) == public_model), public_model) rule = {**default_rule(source), **data["models"].get(source, {})} if not rule["enabled"]: _deny("模型已停用") - if source == public_model and rule["public_id"] != source and not rule["keep_original"]: + if source == public_model and (rule["custom"] or (rule["public_id"] != source and not rule["keep_original"])): _deny("原模型 ID 已停用,请使用已配置的对外 ID", "model_renamed") + if state is None and rule["upstream_id"] != source: + raise HTTPException(status_code=503, detail={"error": {"message": "模型路由上下文未初始化", + "type": "server_error", "code": "model_policy_context_missing"}}) if state is not None: - state.update(source=source, public_model=public_model, rule=rule) - return source + state.update(source=source, public_model=public_model, upstream=rule["upstream_id"], rule=rule) + return rule["upstream_id"] -def check_resolved(config, source): - if not rule_for(config, source)["enabled"]: +def check_resolved(config, upstream): + rule, state = _active(config, upstream) + if state and state["rule"] != rule: + _deny("模型策略已变更,请重新请求", "model_policy_changed") + if not rule["enabled"]: _deny("模型已停用") def route_allowed(config, entry, model, *, rule=None): if not credential_enabled(config, entry): return False - active = rule_for(config, model) if model else default_rule("") if rule is None: - state = _request_policy.get() - if state and state.get("source") == model: - if state["rule"] != active: - return False - rule = state["rule"] - else: - rule = active + active, state = _active(config, model) if model else (default_rule(""), None) + if state and state["rule"] != active: + return False + rule = active if not rule.get("enabled", True): return False profile = entry.get("profile", "") @@ -98,7 +116,8 @@ def route_allowed(config, entry, model, *, rule=None): def sticky_scope(config, key, model): if key and config.get("control_store") is not None: - return f"policy:{snapshot(config)['revision']}:{model}:{key}" + state = _request_policy.get() or {} + return f"policy:{snapshot(config)['revision']}:{state.get('source', model)}:{key}" return key @@ -107,21 +126,30 @@ def public_name(fallback): def public_details(gateway, region=None): - """Only advertise capabilities and prices from credentials inside the rule.""" + """Publish each route using only its upstream capabilities and allowed accounts.""" config = gateway.CONFIG raw = gateway.current_model_details(region) if config.get("control_store") is None: return raw + known = {item["id"]: item for item in raw} + policies = snapshot(config)["models"] + owners = {rule.get("public_id", source): source for source, rule in policies.items()} pool = config.get("cred_pool") entries = pool.entries() if pool is not None else [] out = [] - for item in raw: - source = item["id"] + for source in dict.fromkeys([*known, *policies]): rule = rule_for(config, source) - if not rule["enabled"]: + upstream = rule["upstream_id"] + item = known.get(upstream) + if not rule["enabled"] or item is None: continue - candidates = [entry for entry in entries if route_allowed(config, entry, source, rule=rule) - and pool._eligible(entry, source, region=region)] + token = _request_policy.set({"source": source, "upstream": upstream, + "public_model": rule["public_id"], "rule": rule}) + try: + candidates = [entry for entry in entries if route_allowed(config, entry, upstream, rule=rule) + and pool._eligible(entry, upstream, region=region)] + finally: + _request_policy.reset(token) if pool is not None and not candidates: continue prices = {} @@ -129,9 +157,8 @@ def public_details(gateway, region=None): if accounts is not None: for entry in candidates: profile = entry.get("profile") - for model in gateway._account_scope( - accounts.get(entry.get("account_key")) or {}, "serves") or []: - if model.get("id") == gateway._upstream_model(source, profile): + for model in gateway._account_scope(accounts.get(entry.get("account_key")) or {}, "serves") or []: + if model.get("id") == gateway._upstream_model(upstream, profile): price = gateway._multiplier_value(model.get("credits")) if price is not None: prices[profile] = min(prices.get(profile, price), price) @@ -140,9 +167,12 @@ def public_details(gateway, region=None): prices = {profile: price for profile, price in item["credits_by_profile"].items() if not pool or profile in allowed_profiles} names = [rule["public_id"]] - if rule["keep_original"] and source not in names: + if rule["keep_original"] and not rule["custom"] and source not in names: names.append(source) for name in names: + # A later catalog refresh must not overwrite an explicitly configured alias. + if name in owners and owners[name] != source: + continue out.append({"id": name, "credits": min(prices.values(), default=None), "credits_by_profile": prices}) return out diff --git a/app/runtime_management.py b/app/runtime_management.py index cf68197..0011238 100644 --- a/app/runtime_management.py +++ b/app/runtime_management.py @@ -38,7 +38,7 @@ def storage(self): return {"available": False, "degraded": True, "last_error": self.code, "dropped": self.dropped, "warning": "日志存储不可用;保留原文件,未自动重建。统计可能不完整。"} - def dashboard(self, days=30): + def dashboard(self, days=30, granularity="auto"): return {"summary": None, "series": [], "models": [], "profiles": [], "degraded": True, "storage": self.storage()} def list_records(self, *args, **kwargs): diff --git a/app/travel.py b/app/travel.py new file mode 100644 index 0000000..e1665d1 --- /dev/null +++ b/app/travel.py @@ -0,0 +1,118 @@ +"""Domestic Buddy travel: query first, claim arrivals, then dispatch only confirmed idle accounts.""" +import math +import random +import time + +import httpx + +HOST = "https://www.workbuddy.cn" +PREFIX = "/activity/growth/buddy/travel/" +TIMEOUT = 12.0 +LOCATIONS = {1: "咖啡馆", 2: "商场店铺", 3: "健身房", 4: "古镇客栈"} + + +def supported(profile): + return profile in {"cn-cli", "cn-work"} + + +def unavailable(): + return {"ok": False, "skipped": True, "state": "unavailable", "message": "旅行仅适用于国内账号"} + + +def _number(value): + return value if type(value) in (int, float) and 0 <= value <= 1e12 and math.isfinite(value) else None + + +def _request(client, token, operation): + headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"} + if operation == "status": + response = client.get(HOST + PREFIX + operation, headers=headers, timeout=TIMEOUT) + else: + body = {"location_id": random.choice(tuple(LOCATIONS))} if operation == "depart" else {} + response = client.post(HOST + PREFIX + operation, headers=headers, json=body, timeout=TIMEOUT) + payload = response.json() + if (response.status_code != 200 or not isinstance(payload, dict) + or type(payload.get("code")) is not int or payload["code"] != 0 + or not isinstance(payload.get("data"), dict)): + raise ValueError("Travel response was not confirmed") + return payload["data"] + + +def _status(data): + state = data.get("state") + if state not in {"idle", "traveling", "arrived"}: + raise ValueError("Travel state missing") + location = data.get("location") + location_id = location.get("id") if isinstance(location, dict) else None + if type(location_id) is not int or location_id not in LOCATIONS: + location_id = None + return {"state": state, "daily_limit_reached": data.get("daily_limit_reached") + if type(data.get("daily_limit_reached")) is bool else None, + "location_id": location_id, "location_name": LOCATIONS.get(location_id), + "reward_credit": _number(data.get("reward_credit")), + "arrive_at": _number(data.get("arrive_at")), "server_now": _number(data.get("server_now"))} + + +def perform(token, profile, *, read_only=False, can_write=lambda: True): + if not supported(profile): + return unavailable() + result = {"ok": False, "state": "unknown", "claimed": False, "departed": False, "stale": False} + phase = "status" + if not read_only and not can_write(): + return {**result, "skipped": True, "message": "设置或凭证已变化,未执行旅行操作"} + try: + with httpx.Client(follow_redirects=False) as client: + result.update(_status(_request(client, token, "status"))) + if read_only: + result.update(ok=True, message={"idle": "Buddy 空闲", "traveling": "Buddy 旅行中", "arrived": "Buddy 已到达,待领取"}[result["state"]]) + return result + if result["state"] == "arrived": + if not can_write(): + result.update(skipped=True, message="设置或凭证已变化,未发送领取请求") + return result + phase = "claim" + receipt = _request(client, token, "claim") + result.update(claimed=True, claimed_credit=_number(receipt.get("reward_credit"))) + phase = "after_claim" + result.update(_status(_request(client, token, "status"))) + if result["state"] == "arrived": + result.update(stale=True, message="领取已确认,但状态尚未更新,未派出") + return result + prefix = "旅行积分已领取;" if result["claimed"] else "" + if result["state"] == "traveling": + result.update(ok=True, skipped=True, message=prefix + "Buddy 旅行中,无需派遣") + return result + if result["daily_limit_reached"] is True: + result.update(ok=True, skipped=True, message=prefix + "今日派遣已达上限") + return result + if result["daily_limit_reached"] is not False: + result.update(message=prefix + "派遣上限状态未知,未派出") + return result + if not can_write(): + result.update(skipped=True, message=prefix + "设置或凭证已变化,未发送派遣请求") + return result + phase = "depart" + receipt = _request(client, token, "depart") + result.update(ok=True, departed=True, state="traveling", stale=False, + arrive_at=_number(receipt.get("arrive_at")), server_now=_number(receipt.get("server_now")), + message=prefix + "Buddy 已派出,余额可另行同步") + location = receipt.get("location") + location_id = location.get("id") if isinstance(location, dict) else None + result.update(location_id=location_id if type(location_id) is int and location_id in LOCATIONS else None) + result["location_name"] = LOCATIONS.get(result["location_id"]) + return result + except (httpx.HTTPError, ValueError, TypeError): + messages = {"status": "旅行状态查询失败,未执行写操作", "claim": "领取结果未确认,未派出;下次先查询状态", + "after_claim": "领取已确认,后续状态查询失败,未派出", "depart": "派遣结果未确认;下次先查询状态"} + result.update(ok=False, stale=True, message=messages[phase]) + return result + + +def remember(ledger, cid, result): + record = {**result, "at": time.time()} + previous = ledger.entry(cid).get("travel") or {} + if not result.get("ok"): + known = previous if previous.get("ok") else previous.get("last_success") + if known: + record["last_success"] = {key: value for key, value in known.items() if key != "last_success"} + ledger.update_travel(cid, record) diff --git a/converter.py b/converter.py index a61becc..4ec1db7 100644 --- a/converter.py +++ b/converter.py @@ -66,7 +66,7 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False, from app import auth_oauth from app import trial_rewards -from app import model_policy +from app import checkin as checkin_service, model_policy, travel from app.model_blocks import ModelBlocks from app.observability import (AuditMiddleware, observe_route, observe_usage, observe_attempt, observe_failure) @@ -716,8 +716,8 @@ def _has_credit(self, entry, profile): except (TypeError, ValueError): return False - def _eligible(self, entry, model, *, region=None, profile=None): - if not model_policy.route_allowed(CONFIG, entry, model): + def _eligible(self, entry, model, *, region=None, profile=None, rule=None): + if not model_policy.route_allowed(CONFIG, entry, model, rule=rule): return False actual = self._entry_profile(entry) profile = profile or actual @@ -1113,7 +1113,7 @@ def _sync_trial(headers): _log(f"[trial] 领取失败(不影响余额同步): {_network_error_text(error)}") -def _sync_credits(pool, ledger, entry, *, checkin, failed): +def _sync_credits(pool, ledger, entry, *, checkin, failed, claim_trial=True, expected_identity=None): if not model_policy.credential_enabled(CONFIG, entry): return None cm, cid = entry["cm"], entry["id"] @@ -1121,17 +1121,33 @@ def _sync_credits(pool, ledger, entry, *, checkin, failed): try: with cm._lock: try: + if expected_identity is not None and cm.summary().get("account_key") != expected_identity: + failed.add(cid) + return None headers = cm.get_headers() finally: generation = cm._generation + profile = profile_for_headers(headers) + identity = account_key(profile, headers.get("X-User-Id"), headers.get("X-Enterprise-Id")) + if entry.get("account_key") and entry["account_key"] != identity: + failed.add(cid) + return None # A path now owned by another account must be rescheduled with its own preferences. site = site_for_headers(headers) token, uid, domain = _bearer_token(headers), headers.get("X-User-Id", ""), headers.get("X-Domain", "") day = time.strftime("%Y-%m-%d") - if checkin and not ledger.checkin_done(cid, day): + if checkin and model_policy.credential_auto_checkin(CONFIG, entry) and not ledger.checkin_done(cid, day): try: - result = credits_mod.daily_checkin(token, uid=uid, domain=domain) - if not pool.apply_if_current(cm, generation, lambda: ledger.mark_checkin( - cid, day, result["ok"], result.get("code"), result.get("message", ""))): + def can_claim(): + return (model_policy.credential_auto_checkin(CONFIG, entry) + and pool.apply_if_current(cm, generation, lambda: None)) + result = checkin_service.perform(token, uid=uid, domain=domain, can_claim=can_claim) + if result["state"] == "cancelled": + if not pool.apply_if_current(cm, generation, lambda: None): + failed.add(cid) + return None + # A preference-only cancellation must not interrupt balance refresh. + elif not pool.apply_if_current(cm, generation, lambda: ledger.mark_checkin( + cid, day, result["ok"], result.get("code"), result["message"], state=result["state"])): failed.add(cid) return None _log(f"[checkin] {Path(cid).name}: ok={result['ok']} already={result.get('already')} code={result.get('code')}") @@ -1139,7 +1155,19 @@ def _sync_credits(pool, ledger, entry, *, checkin, failed): _sync_error(pool, ledger, entry, generation, "checkin", error) if not model_policy.credential_enabled(CONFIG, entry): return None - _sync_trial(headers) + if checkin and model_policy.credential_auto_travel(CONFIG, entry): + try: + def can_travel(): + return (model_policy.credential_auto_travel(CONFIG, entry) + and pool.apply_if_current(cm, generation, lambda: None)) + trip = travel.perform(token, profile_for_headers(headers), can_write=can_travel) + if not pool.apply_if_current(cm, generation, lambda: travel.remember(ledger, cid, trip)): + failed.add(cid) + return None + except Exception as error: + _sync_error(pool, ledger, entry, generation, "travel", error) + if claim_trial: + _sync_trial(headers) if not model_policy.credential_enabled(CONFIG, entry): return None balance = credits_mod.fetch_credits(token, uid=uid, domain=domain) @@ -1218,18 +1246,25 @@ def publish(): _publish_model_cache() -def _sync_usage(pool): +def _sync_usage(pool, entries=None, expected_identity=None): """历史用量仅在定时/手动维护时同步;每账号独立快照,单账号失败只替换自身数据。""" accounts = CONFIG.get("usage_daily_accounts") if not isinstance(accounts, dict): accounts = CONFIG["usage_daily_accounts"] = {} - stale = set() - for entry in pool.entries(): + targets = pool.entries() if entries is None else entries + target_ids = {entry["id"] for entry in targets} + previous_stale = set((CONFIG.get("usage_daily") or {}).get("stale_accounts", [])) + stale = {entry["id"] for entry in pool.entries() + if entry["id"] not in target_ids and Path(entry["id"]).name in previous_stale} + for entry in targets: if not model_policy.credential_enabled(CONFIG, entry): continue try: cm = entry["cm"] with cm._lock: + if expected_identity is not None and cm.summary().get("account_key") != expected_identity: + stale.add(entry["id"]) + continue headers = cm.get_headers() generation = cm._generation site = site_for_headers(headers) @@ -1241,11 +1276,13 @@ def store(): "requests": usage["requests"], "partial": bool(usage.get("partial")), "fetched_at": time.time()} - pool.apply_if_current(cm, generation, store) + if not pool.apply_if_current(cm, generation, store): + stale.add(entry["id"]) except Exception as error: stale.add(entry["id"]) _log(f"[usage] {Path(entry['id']).name} 明细拉取失败(保留其上次成功快照): {_network_error_text(error)}") _publish_usage_daily(pool, stale) + return stale & target_ids def _publish_usage_daily(pool, stale=()): @@ -1771,13 +1808,29 @@ def admin_model_blocks(authorization: Optional[str] = Header(default=None), @app.post("/admin/checkin") def admin_checkin(authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """手动触发一轮签到 + 积分刷新(签到按日幂等,已签则只刷积分)。""" + """手动签到(按日幂等),余额和用量由独立同步操作更新。""" + _check_admin_auth(authorization, x_api_key) + return _admin_credential_action("checkin") + + +def _admin_credential_action(action, identity=None): + from app.credential_actions import run + return run(sys.modules[__name__], action, identity) + + +@app.post("/admin/sync") +def admin_sync(authorization: Optional[str] = Header(default=None), + x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): + _check_admin_auth(authorization, x_api_key) + return _admin_credential_action("sync") + + +@app.post("/admin/credentials/{identity}/{action}") +def admin_credential_action(identity: str, action: str, + authorization: Optional[str] = Header(default=None), + x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): _check_admin_auth(authorization, x_api_key) - pool, ledger = CONFIG.get("cred_pool"), CONFIG.get("ledger") - if pool is None or ledger is None: - raise HTTPException(status_code=503, detail={"error": {"message": "签到调度未启用", "type": "invalid_request_error"}}) - _housekeep_once(pool, ledger) - return {"credits": ledger.snapshot()} + return _admin_credential_action(action, identity) # --------------------------------------------------------------------------- @@ -3148,7 +3201,8 @@ def main(): sys.stderr.write(" 添加账号:python3 converter.py login(自动等待扫码并保存)\n") if credits_mod is not None: sys.stderr.write(" GET /admin/credits (积分/签到状态)\n") - sys.stderr.write(" POST /admin/checkin (手动触发签到+积分刷新)\n") + sys.stderr.write(" POST /admin/checkin (仅签到,按日幂等)\n") + sys.stderr.write(" POST /admin/sync (同步余额、目录与用量,不签到)\n") sys.stderr.write(" 每日签到 + 快过期积分优先调度已启用\n") if args.api_key: sys.stderr.write(" 鉴权已启用(API key 已设置)\n") diff --git a/docs/advanced.md b/docs/advanced.md index 25d7480..fe5a6cf 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -70,9 +70,11 @@ Use a source/image build and Compose configuration containing this feature; recr | `GET /admin/credentials` | Credential inventory and runtime state | | `POST /admin/credentials` | Import an `.info` file from the server's controlled directory | | `DELETE /admin/credentials/{name}` | Delete the credential file by filename; returns 409 while referenced by model bindings | -| `PATCH /admin/credentials/{id}` | Enable/disable by account identity ID, without deleting files | +| `PATCH /admin/credentials/{id}` | One Boolean field per account: `enabled`, `auto_checkin`, or `auto_travel`; automation preference saves do not claim immediately | | `POST /admin/oauth/start` · `GET /admin/oauth/poll` | Start/poll login; `site=cn` (default), `intl` (international WorkBuddy) or `intl-codebuddy` (international CodeBuddy) | -| `GET /admin/credits` · `POST /admin/checkin` | Inspect credits; trigger check-in and credit synchronization | +| `GET /admin/credits` · `POST /admin/checkin` | Inspect credits; daily-idempotent check-in followed by domestic travel when enabled | +| `POST /admin/sync` | Synchronize all enabled accounts' balances, catalogs and usage; no check-in or trial claims | +| `POST /admin/credentials/{id}/{action}` | Single-account `refresh`, `checkin`, `sync`, `travel-status` (query only), or `travel` (claim then dispatch) | Pages use `/dashboard/*`, management APIs use `/admin/*`, and clients retain `/v1/*`. `/cn` and `/intl` API prefixes are not registered. Automatic model routing requires no client URL changes. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index b67e91d..a8467ac 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -70,9 +70,11 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 | `GET /admin/credentials` | 凭证列表与运行状态 | | `POST /admin/credentials` | 从服务端受控目录导入 `.info` | | `DELETE /admin/credentials/{name}` | 按文件名删除凭证文件;仍被模型绑定引用时返回 409 | -| `PATCH /admin/credentials/{id}` | 按账号身份 ID 启停凭证,不删除文件 | +| `PATCH /admin/credentials/{id}` | 按账号设置单个布尔字段:`enabled`、`auto_checkin` 或 `auto_travel`;保存自动任务开关不立即领取 | | `POST /admin/oauth/start` · `GET /admin/oauth/poll` | 发起与轮询登录;`site=cn`(默认)、`intl`(国际 WorkBuddy)或 `intl-codebuddy`(国际 CodeBuddy) | -| `GET /admin/credits` · `POST /admin/checkin` | 查询额度、手动触发签到与额度同步 | +| `GET /admin/credits` · `POST /admin/checkin` | 查询额度;按日幂等签到,国内按开关继续旅行 | +| `POST /admin/sync` | 同步全部启用账号的余额、目录和用量,不签到、不领取试用 | +| `POST /admin/credentials/{id}/{action}` | 单账号 `refresh`、`checkin`、`sync`、`travel-status`(仅查询)或 `travel`(领取后派出) | 页面使用 `/dashboard/*`,管理 API 使用 `/admin/*`,客户端保留原 `/v1/*`;不注册 `/cn`、`/intl` API 前缀。模型自动选路不要求客户端改变地址。 diff --git a/docs/deployment.md b/docs/deployment.md index c705733..38831e0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -48,10 +48,9 @@ After editing `.env`, repeat the appropriate `docker compose up -d` command to r Requires Python, uv, and Node.js with the vp CLI to build the interface: ```bash -uv venv -uv pip install --require-hashes --only-binary=:all: -r requirements.txt +uv sync --locked --no-build --python 3.12 (cd web && vp install --frozen-lockfile && vp build) -uv run --env-file .env converter.py --desensitize +uv run --locked --no-build --env-file .env converter.py --desensitize ``` Configure `.env` as above before starting, then open `/dashboard` to add accounts. Rebuild the WebUI after changing frontend source. @@ -62,13 +61,14 @@ Local Python binding uses `--host` and `--port`. Compose-only `CODEBUDDY2API_BIN ## Dependency locks -`requirements.in` lists direct dependencies; install the committed, hash-locked `requirements.txt`. To regenerate it with uv: +`pyproject.toml` owns direct dependencies; `uv.lock` pins all resolved versions. `requirements.in` and the hash-locked `requirements.txt` are generated compatibility files for pip, Docker and CI: ```bash -uv pip compile --universal --python-version 3.12 --no-python-downloads --generate-hashes requirements.in -o requirements.txt +uv lock +python3 scripts/export_requirements.py ``` -Existing pins are reused; use `--upgrade-package NAME` only for deliberate updates and review the lockfile diff. Installation requires matching binary wheels and hashes; fix the lock or roll back rather than disabling those checks. +Use `uv add`/`uv remove` for intentional dependency changes, then export and review both locks. Normal startup uses `--locked` and never upgrades packages. Metadata stays at the current `VERSION`; releases must update both version fields. Pip/Docker still require matching hashes and binary wheels; do not disable these checks. Docker's build frontend, Node and Python images are pinned by multi-platform digest. When refreshing them, retain `linux/amd64` and `linux/arm64` support and verify the build. Locks prevent drift, not future vulnerabilities; security updates still require reviewed refreshes. @@ -78,9 +78,9 @@ Docker's build frontend, Node and Python images are pinned by multi-platform dig When the WebUI is unavailable, browser login also works without starting the server: ```bash -uv run --env-file .env converter.py login -uv run --env-file .env converter.py login --site intl --no-browser -uv run --env-file .env converter.py login --site intl-codebuddy --no-browser +uv run --locked --no-build --env-file .env converter.py login +uv run --locked --no-build --env-file .env converter.py login --site intl --no-browser +uv run --locked --no-build --env-file .env converter.py login --site intl-codebuddy --no-browser ``` The first command uses the domestic site. `--site intl` selects international WorkBuddy (`www.workbuddy.ai`); `--site intl-codebuddy` selects international CodeBuddy (`www.codebuddy.ai`). `--no-browser` prints a link you can open on another device. Even after the browser reports success, wait for the terminal to confirm that credentials were saved. Links expire after 10 minutes; press `Ctrl+C` to cancel. diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md index 9e46c2a..8346cdc 100644 --- a/docs/deployment.zh-CN.md +++ b/docs/deployment.zh-CN.md @@ -48,10 +48,9 @@ docker compose up -d --no-build 需要 Python、uv,以及构建界面的 Node.js 和 vp CLI: ```bash -uv venv -uv pip install --require-hashes --only-binary=:all: -r requirements.txt +uv sync --locked --no-build --python 3.12 (cd web && vp install --frozen-lockfile && vp build) -uv run --env-file .env converter.py --desensitize +uv run --locked --no-build --env-file .env converter.py --desensitize ``` 先按上文配置 `.env`,再启动服务并进入 `/dashboard` 添加账号。更改前端源码后需重新构建 WebUI。 @@ -62,13 +61,14 @@ uv run --env-file .env converter.py --desensitize ## 依赖锁定 -`requirements.in` 维护直接依赖;安装使用已提交、带哈希的 `requirements.txt`。用 uv 重新生成: +`pyproject.toml` 是直接依赖入口,`uv.lock` 锁定完整依赖;`requirements.in` 和带哈希的 `requirements.txt` 是供 pip、Docker、CI 使用的生成文件: ```bash -uv pip compile --universal --python-version 3.12 --no-python-downloads --generate-hashes requirements.in -o requirements.txt +uv lock +python3 scripts/export_requirements.py ``` -默认沿用已有固定版本;有意升级时再用 `--upgrade-package NAME`,并审阅锁文件差异。安装要求匹配的二进制 wheel 和哈希;失败时修正锁文件或回退,不关闭检查。 +有意改动依赖时使用 `uv add`/`uv remove`,然后导出并审阅锁文件差异。日常启动使用 `--locked`,不升级依赖;项目元数据版本保持与 `VERSION` 一致,发版时同步两处。pip/Docker 安装仍要求二进制 wheel 与哈希匹配,不关闭检查。 Docker 构建前端、Node 和 Python 镜像按多架构 digest 固定。更新时保留 `linux/amd64`、`linux/arm64` 并验证构建。锁定防止漂移,不代替后续安全更新。 @@ -78,9 +78,9 @@ Docker 构建前端、Node 和 Python 镜像按多架构 digest 固定。更新 无法使用 WebUI 时也可扫码登录,无需先启动服务: ```bash -uv run --env-file .env converter.py login -uv run --env-file .env converter.py login --site intl --no-browser -uv run --env-file .env converter.py login --site intl-codebuddy --no-browser +uv run --locked --no-build --env-file .env converter.py login +uv run --locked --no-build --env-file .env converter.py login --site intl --no-browser +uv run --locked --no-build --env-file .env converter.py login --site intl-codebuddy --no-browser ``` 第一条默认国内站;`--site intl` 登录国际 WorkBuddy(`www.workbuddy.ai`),`--site intl-codebuddy` 登录国际 CodeBuddy(`www.codebuddy.ai`)。`--no-browser` 只显示链接,可在其他设备打开扫码。网页显示登录成功后,仍需等待终端确认「账号已保存」。链接 10 分钟内有效,`Ctrl+C` 可取消。 diff --git a/docs/webui.md b/docs/webui.md index 605c08d..60f85e1 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -10,15 +10,33 @@ Management is locked without a key. After changing it, sign in again and restart ## Common tasks -- **Overview:** view requests, usage and credential health. Official account balances may include usage from other clients. +- **Overview:** select 1/7/30/90 days and automatic/hourly/daily granularity. Automatic uses hours for one day and days otherwise; ranges still follow UTC calendar days. Missing hourly history is marked, never reconstructed from daily totals, and detail cleanup preserves hourly aggregates. Official balances may include usage from other clients. + - Hover or tap the trend for the period's requests, successes and failures; keyboard arrows and Home/End select points. Missing hourly spans stay empty. - **Credentials:** select Mainland China (CN), International WorkBuddy or International CodeBuddy for browser login, or import `.info`/ZIP files. Distinguish manual disabling, credential-level authentication circuits and model-level 429 cooldowns. Disabling keeps files; deletion removes them and requires removing model bindings first. Exports preserve safe UTF-8 filenames (otherwise `credential.info`/`credentials.zip`) and contain plaintext credentials; do not share them. -- **Models:** enable models, set public IDs, regions/products and specific accounts. Unavailable bindings never fall back to unselected accounts. Clients use the IDs published here. + - Successful OAuth enrollment closes the drawer and refreshes the list. The official tab keeps `noopener` isolation and must be closed manually. Failures stay visible; closing the drawer stops polling. + - Each row offers Token refresh, check-in and balance sync; batch check-in and sync stay separate. Sync updates balances, catalogs and usage without check-in, travel or trial claims. Busy maintenance returns 409; a client timeout does not cancel server work. + - Automatic check-in and travel are persisted per account and apply live: on by default for domestic accounts, off internationally. International check-in can be enabled without a code update; inactive or unconfirmed activities never authorize claims. Disabled accounts run no automatic tasks. + - Domestic check-in is followed by travel when its independent switch is on; already-checked-in accounts and accounts with automatic check-in off still check travel. “Travel status” only queries; “Claim / dispatch” claims arrivals, then rechecks idle state and the daily limit before randomly choosing location 1–4. Traveling accounts are not dispatched, and failed writes are not blindly retried. + - Saving a preference does not claim immediately; it affects subsequent maintenance and cannot retract sent requests. The console shows last results, partial completion and uncertainty, retaining history on failure. +- **Models:** add independent mappings with public/upstream IDs and local enablement. Choose either specific accounts or a region with an optional product filter; switching modes clears the opposite binding. Unavailable candidates never cause out-of-scope fallback. - **Logs:** filter requests and inspect failed attempts. Closing details or switching log type cancels pending detail loads. Clearing details keeps historical statistics. - **Settings:** edit unlocked options; hot changes apply immediately, while restart-marked settings require a manual restart. Change locked options in the startup configuration; see [configuration precedence](advanced.md). - “Keep tool descriptions” is off by default and works across all three protocols, independently of prompt compaction; see [tool metadata retention](advanced.md#tool-metadata-retention) for configuration and limits. Clearing **all logs and statistics** is irreversible. Enter the confirmation text shown in the dialog and re-enter the current API key. This does not delete credentials or gateway settings. +The sidebar remembers its icon-only mode. Drawers lock background scrolling, close on backdrop clicks or Esc, and restore focus; saves, imports and deletions prevent accidental dismissal while pending. Details use labeled fields and status groups with folded raw diagnostics. Glass surfaces fall back to solid colors when transparency is reduced or blur is unsupported. + +The appearance icon offers light, dark and system-following modes. Four palettes affect light mode only; dark mode stays fixed and the light preference is retained. Preferences are browser-local. Drawers fade/slide in and out, retaining the scroll lock through exit; reduced-motion settings skip animation. + +## Model mappings and statistics API + +Multiple independent mappings may share an upstream model. Custom mapping IDs are management-only; clients use `public_id`. Existing account capability, balance and avoidance checks still apply. Legacy combined account/region scopes keep their original intersection until an explicit mode is selected during editing. + +- Create: `POST /admin/models` with `public_id`, `upstream_id` and scope; edit: `PUT /admin/models/{id}`; delete: `DELETE /admin/models/{id}` (custom mappings only). Writes require the current `revision`. +- Preview: `POST /admin/models/preview` or `POST /admin/models/{id}/preview`. `credential_ids` is mutually exclusive with `region`/`profile`. +- Statistics: `GET /admin/dashboard?days=1&granularity=auto`, with `auto`, `hour` or `day`. Responses identify `range.granularity`, `range.partial` and UTC buckets without inventing missing hourly history. + ## Data and backups Data defaults to `auth/`, or `/data/auth` inside Docker. Local installs can set `CODEBUDDY_AUTH_DIR`; Compose uses `CODEBUDDY2API_AUTH_PATH` for the host directory. @@ -35,4 +53,6 @@ Mount the whole data directory on writable local storage, not just a single data Stop the gateway before copying the entire directory, including databases, any WAL/SHM files, credentials and catalog/credit state files; do not back up only `.info` files. Keep this private data secure. +Back up control metadata before using new model rules or automation preferences. Reverting to older code requires the matching control-database snapshot, including its WAL/SHM state without mixing files; older readers reject the new fields. Rollback cannot undo completed upstream check-ins, claims or travel dispatches. + See [client configuration](clients.md) for API keys and URLs. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 4363b1a..27b6bf3 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -10,15 +10,33 @@ ## 常用操作 -- **运行概览**:查看请求、用量和凭证健康状态。官方账号余额可能包含其他客户端的使用。 +- **运行概览**:切换 1/7/30/90 天范围与自动/小时/天粒度;自动模式下 1 天按小时,其余按天,范围仍按 UTC 自然日计算。缺少小时历史时明确提示,不拆分日统计伪造数据;明细清理不删除小时聚合。官方账号余额可能包含其他客户端的使用。 + - 悬浮或点按趋势查看时段的请求、成功和失败数;键盘方向键及 Home/End 可逐点选择,缺失小时仍留空。 - **凭证管理**:选择“中国大陆 · CN”“国际 · WorkBuddy”或“国际 · CodeBuddy”扫码添加账号,也可导入 `.info`、ZIP 文件。区分人工停用、凭证级认证熔断和模型级 429 冷却;停用保留文件,删除会移除文件,仍有模型绑定时需先解除。导出保留安全的 UTF-8 文件名,否则使用 `credential.info`/`credentials.zip`;文件含明文凭证,请勿分享。 -- **模型路由**:设置模型启停、对外 ID、地域/产品和指定账号;绑定不可用时不会回退到未选账号。客户端使用这里发布的 ID。 + - OAuth 入库成功后自动关闭添加抽屉并刷新列表;官方标签页保留 `noopener` 安全隔离,需手动关闭。失败保留错误,关闭抽屉会停止查询。 + - 每行可刷新 Token、签到、同步余额;顶部批量签到和同步分开。同步仅更新余额、目录和用量,不签到、不旅行、不领取试用。维护繁忙时返回 409;客户端超时不代表后台任务已取消。 + - 自动签到、自动旅行按账号保存并热生效,国内默认开启,国际默认关闭。国际签到可随时开启,不需更新代码;官方活动未开放或状态不明时不领取。账号停用期间不执行自动任务。 + - 国内签到处理后按自动旅行开关继续旅行;已签到或关闭自动签到时,旅行仍按独立开关检查。手动“旅行状态”只查询,“旅行领派”先领取已到达奖励,再确认空闲且未达上限后随机派往 1–4 号地点;旅行中不派出,写请求失败不盲目重试。 + - 保存开关不会立即领取,从后续维护起生效;关闭不撤回已发请求。界面显示上次结果,部分成功与状态未确认单独提示,失败保留历史。 +- **模型路由**:新增模型映射,独立设置对外 ID、上游 ID 与启停;指定账号和指定区域二选一,区域内可筛产品。切换方式清除另一种绑定,候选不可用时不会越界回退。 - **日志审计**:筛选请求、查看失败详情。关闭详情或切换日志类型会取消未完成的详情加载。清空明细会保留历史统计。 - **系统设置**:修改可编辑项;热更新项立即生效,标记为重启生效的设置需手动重启。锁定项在启动配置中修改,优先级见 [进阶参考](advanced.zh-CN.md)。 - “保留工具描述”默认关闭,可为三协议保留工具说明;独立于提示词压缩,配置与限制见 [工具描述保留](advanced.zh-CN.md#工具描述保留)。 **全部清空日志与统计不可撤销**,需输入弹窗中的确认文字并复核当前 API key;不会删除凭证或网关配置。 +侧栏可折叠为图标并记住偏好;抽屉锁定背景滚动,支持点击遮罩、Esc 关闭和焦点恢复,保存/导入/删除等操作提交中防止误关。详情使用字段与状态分组,原始诊断折叠展示;玻璃层在低透明度偏好或不支持模糊时回退为实色。 + +右上角外观图标提供浅色、深色、跟随系统;四组配色仅影响浅色,深色固定,浅色偏好保留在本机浏览器。抽屉淡入滑入、退出后才解锁背景;系统“减少动态效果”设置会跳过动画。 + +## 模型映射与统计接口 + +同一上游可设置多条独立映射;自建映射的内部 `id` 仅用于管理,客户端使用 `public_id`。新增映射仍遵守既有账号能力、余额和避让检查。旧版账号/区域联合规则保留原交集,编辑时须明确选择一种绑定。 + +- 创建:`POST /admin/models`,设置 `public_id`、`upstream_id` 及路由范围;编辑:`PUT /admin/models/{id}`;删除:`DELETE /admin/models/{id}`(仅自建映射)。写请求携带当前 `revision`。 +- 预览:`POST /admin/models/preview` 或 `POST /admin/models/{id}/preview`;账号使用 `credential_ids`,区域使用 `region`(可附 `profile`),两组互斥。 +- 统计:`GET /admin/dashboard?days=1&granularity=auto`,粒度为 `auto`、`hour` 或 `day`;返回 `range.granularity`、`range.partial` 和 UTC 时段,不补造缺失小时记录。 + ## 数据与备份 数据默认保存在 `auth/`,Docker 内为 `/data/auth`;本地可通过 `CODEBUDDY_AUTH_DIR` 指定目录,Compose 宿主机目录由 `CODEBUDDY2API_AUTH_PATH` 控制。 @@ -35,4 +53,6 @@ 备份前先停止网关,再复制整个目录,包含数据库、可能存在的 WAL/SHM 文件、凭证及目录/积分状态文件;不要只备份 `.info`。备份包含私有数据,请妥善保管。 +使用新版模型规则或自动任务开关前请备份控制库;回滚旧代码时成套恢复对应备份(含其 WAL/SHM 状态,不混用新旧文件),旧版不识别新增字段。回滚不能撤销官方已完成的签到、领取或旅行派遣。 + 客户端密钥和地址的填写方式见 [客户端配置](clients.zh-CN.md)。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f7c5cf4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "codebuddy2api" +version = "1.2.1" +description = "Personal CodeBuddy API gateway and management interface" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.141.1", + "httpx>=0.28.1", + "pytest>=9.1.1", + "uvicorn[standard]>=0.52.4", +] + +[tool.uv] +# Run from source, without building/installing the gateway as a Python package. +package = false diff --git a/requirements.in b/requirements.in index e2305ba..13a8f8d 100644 --- a/requirements.in +++ b/requirements.in @@ -1,5 +1,5 @@ -# Direct dependencies; requirements.txt is the generated, hash-locked install file. -fastapi -uvicorn[standard] -httpx -pytest +# Generated from pyproject.toml by scripts/export_requirements.py; do not edit. +fastapi>=0.141.1 +httpx>=0.28.1 +pytest>=9.1.1 +uvicorn[standard]>=0.52.4 diff --git a/requirements.txt b/requirements.txt index 930c914..64fa7c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --universal --python-version 3.12 --no-python-downloads --generate-hashes requirements.in -o requirements.txt +# uv export --locked --no-dev --no-emit-project --format requirements-txt --output-file requirements.txt annotated-doc==0.0.5 \ --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb @@ -32,7 +32,7 @@ colorama==0.4.6 ; sys_platform == 'win32' \ fastapi==0.141.1 \ --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \ --hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1 - # via -r requirements.in + # via codebuddy2api h11==0.16.0 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 @@ -45,60 +45,39 @@ httpcore==1.0.9 \ # via httpx httptools==0.8.0 \ --hash=sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683 \ - --hash=sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb \ --hash=sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b \ --hash=sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527 \ - --hash=sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124 \ --hash=sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca \ --hash=sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081 \ --hash=sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c \ --hash=sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77 \ --hash=sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09 \ --hash=sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f \ - --hash=sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085 \ - --hash=sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376 \ --hash=sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5 \ - --hash=sha256:5d7fa4ba7292c1139c0526f0b5aad507c6263c948206ea1b1cbca015c8af1b62 \ - --hash=sha256:5eb911c515b96ee44bbd861e42cbefc488681d450545b1d02127f6136e3a86f5 \ --hash=sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8 \ --hash=sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681 \ --hash=sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999 \ --hash=sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1 \ - --hash=sha256:7b71e7d7031928c650e1006e6c03e911bf967f7c69c011d37d541c3e7bf55005 \ --hash=sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d \ --hash=sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d \ --hash=sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d \ - --hash=sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d \ - --hash=sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba \ - --hash=sha256:9fc1644f415372cec4f8a5be3a64183737398f10dbb1263602a036427fe75247 \ --hash=sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745 \ - --hash=sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07 \ --hash=sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b \ - --hash=sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4 \ --hash=sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2 \ - --hash=sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557 \ - --hash=sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d \ - --hash=sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826 \ - --hash=sha256:c08ffe3e79756e0963cbc8fe410139f38a5884874b6f2e17761bef6563fdcd9b \ - --hash=sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813 \ --hash=sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0 \ --hash=sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150 \ --hash=sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e \ - --hash=sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77 \ --hash=sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568 \ --hash=sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6 \ - --hash=sha256:df31ef5494f406ab6cf827b7e64a22841c6e2d654100e6a116ea15b46d02d5e8 \ --hash=sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b \ --hash=sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7 \ - --hash=sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168 \ --hash=sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a \ - --hash=sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0 \ - --hash=sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72 + --hash=sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0 # via uvicorn httpx==0.28.1 \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad - # via -r requirements.in + # via codebuddy2api idna==3.19 \ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 @@ -125,7 +104,6 @@ pydantic-core==2.46.5 \ --hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \ --hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \ --hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \ - --hash=sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c \ --hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \ --hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \ --hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \ @@ -133,114 +111,60 @@ pydantic-core==2.46.5 \ --hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \ --hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \ --hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \ - --hash=sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25 \ - --hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d \ - --hash=sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6 \ - --hash=sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47 \ --hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \ - --hash=sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8 \ --hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \ --hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \ - --hash=sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e \ --hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \ - --hash=sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0 \ --hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \ - --hash=sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f \ --hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \ --hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \ - --hash=sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736 \ - --hash=sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0 \ - --hash=sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7 \ - --hash=sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c \ - --hash=sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4 \ --hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \ - --hash=sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c \ - --hash=sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069 \ - --hash=sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb \ - --hash=sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061 \ --hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \ --hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \ --hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \ - --hash=sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e \ --hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \ --hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \ --hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \ --hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \ --hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \ --hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \ - --hash=sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be \ - --hash=sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6 \ - --hash=sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793 \ --hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \ - --hash=sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b \ - --hash=sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64 \ --hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \ --hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \ - --hash=sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d \ - --hash=sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168 \ --hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \ --hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \ --hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \ - --hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b \ --hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \ - --hash=sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129 \ - --hash=sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e \ - --hash=sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655 \ --hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \ - --hash=sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec \ - --hash=sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689 \ --hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \ - --hash=sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf \ --hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \ --hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \ - --hash=sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464 \ --hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \ - --hash=sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b \ - --hash=sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f \ - --hash=sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee \ --hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \ --hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \ - --hash=sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6 \ --hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \ - --hash=sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f \ - --hash=sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a \ --hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \ - --hash=sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a \ --hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \ - --hash=sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda \ --hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \ --hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \ --hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \ --hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \ - --hash=sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2 \ --hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \ - --hash=sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461 \ - --hash=sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed \ --hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \ --hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \ - --hash=sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2 \ --hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \ - --hash=sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0 \ - --hash=sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266 \ --hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \ --hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \ - --hash=sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f \ --hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \ --hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \ --hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \ --hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \ --hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \ --hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \ - --hash=sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1 \ - --hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 \ - --hash=sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8 \ --hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \ - --hash=sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a \ --hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \ --hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \ --hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \ - --hash=sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3 \ - --hash=sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13 \ --hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9 # via pydantic pygments==2.21.0 \ @@ -250,84 +174,50 @@ pygments==2.21.0 \ pytest==9.1.1 \ --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c - # via -r requirements.in + # via codebuddy2api python-dotenv==1.2.3 \ --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9 \ --hash=sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35 # via uvicorn pyyaml==6.0.3 \ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ - --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ - --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ - --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ - --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ - --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ - --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ - --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ - --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ - --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ - --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ - --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ - --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ - --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ - --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ - --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ - --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ - --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ - --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ - --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ - --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ - --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ - --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ - --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ - --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ - --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ - --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ - --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ - --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ - --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ - --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ - --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ - --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ - --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ - --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 # via uvicorn starlette==1.6.0 \ @@ -353,78 +243,47 @@ typing-inspection==0.4.4 \ uvicorn==0.52.4 \ --hash=sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86 \ --hash=sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1 - # via -r requirements.in + # via codebuddy2api uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' \ - --hash=sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772 \ --hash=sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e \ - --hash=sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743 \ - --hash=sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54 \ - --hash=sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec \ - --hash=sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659 \ --hash=sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8 \ --hash=sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad \ - --hash=sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7 \ --hash=sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35 \ --hash=sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289 \ --hash=sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142 \ - --hash=sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77 \ - --hash=sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733 \ - --hash=sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd \ - --hash=sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193 \ --hash=sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74 \ --hash=sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0 \ --hash=sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6 \ - --hash=sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473 \ - --hash=sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21 \ - --hash=sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242 \ --hash=sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705 \ - --hash=sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702 \ - --hash=sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6 \ --hash=sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f \ --hash=sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e \ --hash=sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d \ --hash=sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370 \ --hash=sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4 \ - --hash=sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792 \ - --hash=sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa \ --hash=sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079 \ - --hash=sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2 \ - --hash=sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86 \ --hash=sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6 \ - --hash=sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4 \ --hash=sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3 \ --hash=sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21 \ --hash=sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c \ --hash=sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e \ --hash=sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25 \ - --hash=sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820 \ - --hash=sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9 \ --hash=sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88 \ --hash=sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2 \ - --hash=sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c \ - --hash=sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c \ --hash=sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42 # via uvicorn watchfiles==1.2.0 \ --hash=sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9 \ --hash=sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98 \ - --hash=sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551 \ - --hash=sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d \ --hash=sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7 \ - --hash=sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db \ --hash=sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69 \ --hash=sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242 \ --hash=sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925 \ --hash=sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f \ - --hash=sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5 \ --hash=sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5 \ --hash=sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427 \ - --hash=sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19 \ --hash=sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4 \ - --hash=sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e \ --hash=sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa \ --hash=sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba \ - --hash=sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df \ --hash=sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c \ --hash=sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906 \ --hash=sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65 \ @@ -437,75 +296,51 @@ watchfiles==1.2.0 \ --hash=sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33 \ --hash=sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831 \ --hash=sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9 \ - --hash=sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2 \ --hash=sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b \ --hash=sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f \ --hash=sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658 \ --hash=sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579 \ - --hash=sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5 \ --hash=sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0 \ - --hash=sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7 \ --hash=sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666 \ - --hash=sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5 \ - --hash=sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201 \ --hash=sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103 \ --hash=sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6 \ --hash=sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8 \ - --hash=sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1 \ - --hash=sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631 \ --hash=sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898 \ --hash=sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d \ --hash=sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44 \ --hash=sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2 \ --hash=sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5 \ - --hash=sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a \ - --hash=sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1 \ --hash=sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b \ --hash=sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc \ --hash=sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5 \ --hash=sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377 \ - --hash=sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8 \ --hash=sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add \ --hash=sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281 \ --hash=sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9 \ - --hash=sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994 \ --hash=sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0 \ - --hash=sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e \ - --hash=sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0 \ --hash=sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28 \ - --hash=sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7 \ --hash=sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55 \ --hash=sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb \ - --hash=sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07 \ --hash=sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb \ --hash=sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4 \ --hash=sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0 \ --hash=sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e \ --hash=sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4 \ - --hash=sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9 \ --hash=sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06 \ --hash=sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26 \ --hash=sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7 \ - --hash=sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4 \ --hash=sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3 \ --hash=sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3 \ --hash=sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838 \ --hash=sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71 \ --hash=sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488 \ - --hash=sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717 \ --hash=sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d \ --hash=sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44 \ --hash=sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2 \ - --hash=sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b \ --hash=sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2 \ - --hash=sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22 \ - --hash=sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6 \ --hash=sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e \ - --hash=sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310 \ - --hash=sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165 \ --hash=sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5 \ --hash=sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799 \ - --hash=sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8 \ --hash=sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7 \ --hash=sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379 \ --hash=sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925 \ @@ -516,24 +351,18 @@ watchfiles==1.2.0 \ # via uvicorn websockets==17.1 \ --hash=sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d \ - --hash=sha256:00679b7468b4c2b12b0757118174e8eabac56bb2f579a928a104d9554a56e098 \ --hash=sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3 \ --hash=sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a \ - --hash=sha256:020e271205f8ab3406d7a59cd00de6dec722315924411c421bd00642f18bad86 \ --hash=sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f \ - --hash=sha256:054c28db2dcec0e857e3b705d8c28012613e555b38c765d6a4f75340a4fc06a0 \ --hash=sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d \ --hash=sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f \ - --hash=sha256:0c84bdef916556cbe1d5a43b423398be4dd3cba6522b463e53d848578b920695 \ --hash=sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6 \ --hash=sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e \ --hash=sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391 \ --hash=sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927 \ --hash=sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb \ - --hash=sha256:10ecb38ffc05e1841b619d99c725307a223ef9ad58e7b1ed33311d472dc43918 \ --hash=sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d \ --hash=sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394 \ - --hash=sha256:17aa424ab61620aad21b36b2240efc87b500cc496e7d0e999a5c2ae99395e886 \ --hash=sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2 \ --hash=sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13 \ --hash=sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c \ @@ -545,10 +374,8 @@ websockets==17.1 \ --hash=sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5 \ --hash=sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608 \ --hash=sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68 \ - --hash=sha256:2d72879819f5145a342d0030c418702496c65a4b913ef81f5ae944dd91dd50f6 \ --hash=sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256 \ --hash=sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335 \ - --hash=sha256:34879e19bb0a3c44f9317679435aea5327fac993933a704cbf353bf1234b10c7 \ --hash=sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e \ --hash=sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c \ --hash=sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad \ @@ -556,10 +383,8 @@ websockets==17.1 \ --hash=sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51 \ --hash=sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780 \ --hash=sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f \ - --hash=sha256:469355ab1af100b9380f1afb09985019f4a4b94fa1dd0e9396db4361626d7ab8 \ --hash=sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82 \ --hash=sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80 \ - --hash=sha256:47a62d6045c6eaa0d8f97bc2fb68b8cf90077a0cbfd4e83d6f2d2145611ee134 \ --hash=sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc \ --hash=sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d \ --hash=sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b \ @@ -573,7 +398,6 @@ websockets==17.1 \ --hash=sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a \ --hash=sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec \ --hash=sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090 \ - --hash=sha256:65be6bda2b537fefa4b3a5ccd6ab386533ce39dd8fe62433ec90901fdc81752d \ --hash=sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555 \ --hash=sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8 \ --hash=sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7 \ @@ -582,45 +406,34 @@ websockets==17.1 \ --hash=sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9 \ --hash=sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b \ --hash=sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774 \ - --hash=sha256:764cf7bfa149365f32b7a0fd9fed32debdac29dd06295d5635cde1745b446cd8 \ --hash=sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3 \ --hash=sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a \ --hash=sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2 \ --hash=sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb \ - --hash=sha256:8196d217eeca52b9235ee1f8a684a09885a5f953d5a31e80ef915bf2c5c94f9d \ --hash=sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc \ - --hash=sha256:84f25e099fdfe3b09f953d84698f729a1f7d1e99101b2787d7a28ed77b323750 \ --hash=sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab \ --hash=sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24 \ --hash=sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74 \ --hash=sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f \ - --hash=sha256:88b882764ef65147a7a5ae13168dedbe225a04e2ff4858fe543f2c402f093e9c \ --hash=sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2 \ - --hash=sha256:8d1b108bd8f5f6a8b90801f6db3b3858d5deca889acfdb8ac497bbb24e4b0edf \ --hash=sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51 \ --hash=sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db \ --hash=sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6 \ - --hash=sha256:98a5b2589a56a4b4f098b0a958099a4356ab904a7844f1da3841efca469af7e9 \ --hash=sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8 \ --hash=sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729 \ --hash=sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce \ --hash=sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6 \ --hash=sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c \ - --hash=sha256:a62d8c424383c9dc769ff3672018df822603117e32686e567d452ed035b6fb2e \ --hash=sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab \ - --hash=sha256:a9fe648abd1d9b89aebfa30407bfdd08a0271ec5dc7d44a4c6ccd1ce22cf562a \ --hash=sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a \ --hash=sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110 \ --hash=sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257 \ - --hash=sha256:b1bc819c6db90e8f91a38250a1ab4c058261871aa52d2fe36382eddedf146dee \ --hash=sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe \ - --hash=sha256:b3b451fd2723ad3191a209afe6f3f4bc86c83e9a85bdc255353b91803ee6aa66 \ --hash=sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566 \ --hash=sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8 \ --hash=sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a \ --hash=sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51 \ --hash=sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78 \ - --hash=sha256:c89406fa3dcd4aa8662c6406cc5c0de1790e9614d2c3aaf03ca53a8a8ccf3405 \ --hash=sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60 \ --hash=sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435 \ --hash=sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d \ @@ -638,7 +451,6 @@ websockets==17.1 \ --hash=sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151 \ --hash=sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1 \ --hash=sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b \ - --hash=sha256:dcb8d5f7edef7a399d322cf28d4c4e6f98dab64d301c8f50581a1080e5198142 \ --hash=sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067 \ --hash=sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81 \ --hash=sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c \ @@ -646,11 +458,9 @@ websockets==17.1 \ --hash=sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e \ --hash=sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2 \ --hash=sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2 \ - --hash=sha256:edadce7a22052056fd4384543019856b34850363c9d387929f677ae01d79709c \ --hash=sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837 \ --hash=sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23 \ --hash=sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623 \ - --hash=sha256:f47aafd92aa28b941180e6da8a42b0f711851b14b81a5b6bb28dbbb1fa35152c \ --hash=sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4 \ --hash=sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc \ --hash=sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd \ @@ -658,7 +468,6 @@ websockets==17.1 \ --hash=sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4 \ --hash=sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a \ --hash=sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3 \ - --hash=sha256:f8e822efd54137d8cc8310eb64635ab827a4a6c72ff08691f38aa624776d8ecb \ --hash=sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce \ --hash=sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81 \ --hash=sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80 \ diff --git a/scripts/export_requirements.py b/scripts/export_requirements.py new file mode 100644 index 0000000..5cfacfd --- /dev/null +++ b/scripts/export_requirements.py @@ -0,0 +1,22 @@ +"""Export compatibility dependency files from the locked uv project.""" +from pathlib import Path +import subprocess +import tomllib + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + if project["version"] != (ROOT / "VERSION").read_text().strip(): + raise SystemExit("pyproject.toml version must match VERSION") + subprocess.run(["uv", "export", "--locked", "--no-dev", "--no-emit-project", + "--format", "requirements-txt", "--output-file", "requirements.txt"], + cwd=ROOT, check=True, stdout=subprocess.DEVNULL) + (ROOT / "requirements.in").write_text( + "# Generated from pyproject.toml by scripts/export_requirements.py; do not edit.\n" + + "\n".join(project["dependencies"]) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 2317f15..3d2b4f1 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -353,9 +353,28 @@ def test_settings_revision_locked_sources_and_secret_redaction(self): stale = self.client.patch("/admin/settings", headers=self.headers, json={"revision": 0, "values": {"max_images": 6}}) self.assertEqual(stale.status_code, 409) + def test_model_inventory_uses_revision_after_identity_sync(self): + def inventory(): + self.store.update_model("custom:synced", {"public_id": "synced-model", "upstream_id": "upstream", "custom": True}, 0) + return [{"id": "custom:synced", "public_id": "synced-model", "upstream_id": "upstream", "custom": True}] + self.gateway.admin_model_inventory.side_effect = inventory + response = self.client.get("/admin/models", headers=self.headers) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["revision"], 1) + self.assertEqual(response.json()["models"][0]["public_id"], "synced-model") + + + def test_dashboard_granularity_is_validated_and_forwarded(self): + response = self.client.get("/admin/dashboard?days=1&granularity=hour", headers=self.headers) + self.assertEqual(response.status_code, 200, response.text) + self.audit.dashboard.assert_called_with(1, granularity="hour") + for value in ("week", "minute", "unknown"): + self.assertEqual(self.client.get("/admin/dashboard?days=1&granularity=" + value, headers=self.headers).status_code, 400) + + def test_model_rules_preview_and_credential_constraints(self): response = self.client.put("/admin/models/upstream", headers=self.headers, - json={"revision": 0, "public_id": "public", "credential_ids": ["fingerprint"], "region": "cn"}) + json={"revision": 0, "public_id": "public", "credential_ids": ["fingerprint"]}) self.assertEqual(response.status_code, 200, response.text) models = self.client.get("/admin/models", headers=self.headers).json() self.assertEqual(models["models"][0]["public_id"], "public") diff --git a/tests/test_build_lock.py b/tests/test_build_lock.py index e63ba0a..5096f2c 100644 --- a/tests/test_build_lock.py +++ b/tests/test_build_lock.py @@ -2,6 +2,7 @@ from pathlib import Path import re +import tomllib import shlex import unittest @@ -40,6 +41,25 @@ def test_every_resolved_dependency_has_an_exact_version_and_sha256(self): self.assertTrue(direct) self.assertTrue(direct <= locked) + def test_uv_metadata_and_exported_locks_stay_consistent(self): + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + self.assertEqual(project["version"], (ROOT / "VERSION").read_text().strip()) + self.assertEqual(set(project["dependencies"]), set(requirement_lines(ROOT / "requirements.in"))) + lock = tomllib.loads((ROOT / "uv.lock").read_text()) + packages = {p["name"]: p for p in lock["package"] if p["name"] != project["name"]} + exported = set() + for line in requirement_lines(ROOT / "requirements.txt"): + requirement, *hashes = re.split(r"\s+--hash=", line) + parsed = Requirement(requirement) + package = packages[canonicalize_name(parsed.name)] + self.assertIn(package["version"], parsed.specifier) + locked_hashes = {wheel["hash"] for wheel in package.get("wheels", [])} + if package.get("sdist"): + locked_hashes.add(package["sdist"]["hash"]) + self.assertTrue({value.strip() for value in hashes} <= locked_hashes) + exported.add(canonicalize_name(parsed.name)) + self.assertEqual(exported, set(packages)) + def test_external_images_and_build_frontend_are_pinned(self): dockerfile = (ROOT / "Dockerfile").read_text() self.assertRegex(dockerfile.splitlines()[0], r"^# syntax=docker/dockerfile:[^@]+@sha256:[0-9a-f]{64}$") diff --git a/tests/test_control_store.py b/tests/test_control_store.py index 310fe9e..b16ae07 100644 --- a/tests/test_control_store.py +++ b/tests/test_control_store.py @@ -66,6 +66,22 @@ def test_alias_collision_and_chains(self): self.store.update_model(source, rule, 1) self.assertEqual(self.store.snapshot()["revision"], 1) + def test_legacy_combined_scopes_load_without_widening_and_require_explicit_edit(self): + from app import model_policy + legacy = {"public_id": "legacy", "enabled": True, "keep_original": False, + "region": "cn", "profile": None, "credential_ids": ["cn-account", "intl-account"]} + self.store._update(0, lambda state: state["models"].update({"real": legacy})) + reopened = ControlStore(self.path) + self.addCleanup(reopened.close) + self.assertEqual(reopened.snapshot()["models"]["real"], legacy) + config = {"control_store": reopened} + self.assertTrue(model_policy.route_allowed(config, {"profile": "cn-cli", "account_key": "cn-account"}, "real")) + self.assertFalse(model_policy.route_allowed(config, {"profile": "intl-cli", "account_key": "intl-account"}, "real")) + with self.assertRaises(ValueError): + reopened.update_model("real", legacy, 1) + self.assertEqual(reopened.snapshot()["revision"], 1) + + def test_credential_metadata_and_no_secret_settings(self): self.store.set_credential("account-fingerprint", False) self.assertEqual(self.store.snapshot()["credentials"], {"account-fingerprint": {"enabled": False}}) diff --git a/tests/test_credential_actions.py b/tests/test_credential_actions.py new file mode 100644 index 0000000..3739789 --- /dev/null +++ b/tests/test_credential_actions.py @@ -0,0 +1,183 @@ +"""Offline single-account maintenance, authorization, and stale-result contracts.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import time +import unittest +from unittest.mock import patch +from fastapi import FastAPI +from fastapi.testclient import TestClient +import converter +from app.admin_api import install_admin +from app.audit_store import AuditStore +from app.control_store import ControlStore +from app.gateway_management import Management +from tests import test_region_routing as fixtures + + +class CredentialActionTests(unittest.TestCase): + add_account = fixtures.RegionRoutingTests.add_account + configure = fixtures.RegionRoutingTests.configure + handle_upstream = fixtures.RegionRoutingTests.handle_upstream + + def setUp(self): + fixtures.RegionRoutingTests.setUp(self) + self.control = ControlStore(self.root / "control.sqlite3") + self.audit = AuditStore(self.root / "audit.sqlite3") + self.addCleanup(self.control.close) + self.addCleanup(self.audit.close) + converter.CONFIG.update(control_store=self.control, audit_store=self.audit, + api_key="synthetic-management-key", usage_daily_accounts={}, usage_daily={}) + app = FastAPI() + app.router.routes = list(converter.app.router.routes) + install_admin(app, converter.CONFIG, Management(converter)) + self.client = self.enterContext(TestClient(app, base_url="https://testserver", + headers={"Authorization": "Bearer synthetic-management-key"})) + self.status_query = self.enterContext(patch.object(converter.credits_mod, "fetch_checkin_status", + return_value={"ok": False, "state": "available", "code": 0})) + self.travel_mock = self.enterContext(patch("app.travel.perform", + return_value={"ok": True, "skipped": True, "state": "traveling", "message": "Buddy 旅行中"})) + self.entry = self.entries["cn-cli"] + self.url = "/admin/credentials/" + self.entry["account_key"] + + def post(self, action): + response = self.client.post(self.url + "/" + action) + self.assertEqual(response.status_code, 200, response.text) + return response.json()["results"][0] + + def test_single_checkin_is_idempotent_and_does_not_sync(self): + with patch.object(converter.credits_mod, "daily_checkin", return_value={"ok": True}) as checkin, \ + patch.object(converter, "_sync_credits") as sync: + self.assertTrue(self.post("checkin")["ok"]) + self.assertTrue(self.post("checkin")["already"]) + self.assertEqual(checkin.call_count, 1) + self.assertEqual(checkin.call_args.kwargs["uid"], "cn-cli") + sync.assert_not_called() + self.assertFalse(self.ledger.checkin_done(self.entries["intl-work"]["id"], time.strftime("%Y-%m-%d"))) + + def test_single_sync_never_signs_in_or_claims_trial_and_keeps_other_staleness(self): + other = self.entries["intl-work"]["id"] + converter.CONFIG["usage_daily"] = {"stale_accounts": [Path(other).name]} + with patch.object(converter.credits_mod, "daily_checkin") as checkin, \ + patch.object(converter, "_sync_trial") as trial, \ + patch.object(converter.credits_mod, "fetch_credits", return_value={"credits": 80, "intl": False}) as balance, \ + patch.object(converter.credits_mod, "fetch_request_usage", return_value={"by_day": {}, "total_credits": 0, "requests": 0}) as usage: + self.assertTrue(self.post("sync")["ok"]) + checkin.assert_not_called() + trial.assert_not_called() + self.assertEqual(balance.call_count, 1) + self.assertEqual(usage.call_count, 1) + self.assertEqual(usage.call_args.kwargs["uid"], "cn-cli") + self.assertEqual(set(converter.CONFIG["usage_daily_accounts"]), {self.entry["id"]}) + self.assertEqual(converter.CONFIG["usage_daily"]["stale_accounts"], [Path(other).name]) + self.assertNotIn(self.entry["id"], self.pool._sync_pending) + self.assertIn(other, self.pool._sync_pending) + + def test_failed_sync_preserves_balance_and_hides_exception(self): + before = self.ledger.entry(self.entry["id"])["credits"] + with patch.object(converter.credits_mod, "fetch_credits", side_effect=ValueError("synthetic-secret")): + result = self.post("sync") + self.assertFalse(result["ok"]) + self.assertNotIn("synthetic-secret", str(result)) + self.assertEqual(self.ledger.entry(self.entry["id"])["credits"], before) + + def test_partial_sync_reports_incomplete(self): + with patch.object(converter.credits_mod, "fetch_credits", return_value={"credits": 5, "intl": False, "partial": True}), \ + patch.object(converter.credits_mod, "fetch_request_usage", return_value={"by_day": {}, "total_credits": 0, "requests": 0}): + self.assertFalse(self.post("sync")["ok"]) + + def test_explicit_refresh_only_calls_selected_account_and_releases_lock(self): + self.allowed_profiles = {"cn-cli"} + before = self.entry["cm"]._generation + with patch.object(converter, "_sync_credits") as sync: + self.assertTrue(self.post("refresh")["ok"]) + sync.assert_not_called() + self.assertGreater(self.entry["cm"]._generation, before) + self.assertEqual(len(self.requests), 1) + self.assertTrue(str(self.requests[0].url).endswith("/v2/plugin/auth/token/refresh")) + self.assertTrue(converter._HOUSEKEEP_LOCK.acquire(blocking=False)) + converter._HOUSEKEEP_LOCK.release() + + def test_stale_checkin_result_not_published(self): + def changed(*args, **kwargs): + self.entry["cm"].invalidate() + return {"ok": True} + with patch.object(converter.credits_mod, "daily_checkin", side_effect=changed): + self.assertFalse(self.post("checkin")["ok"]) + self.assertFalse(self.ledger.checkin_done(self.entry["id"], time.strftime("%Y-%m-%d"))) + + def test_catalog_sync_queries_only_the_selected_account(self): + converter.CONFIG["model_cache"] = converter.credits_mod.ModelCatalogCache(self.root / "models.json") + with patch.object(converter.credits_mod, "fetch_credits", return_value={"credits": 8, "intl": False}), \ + patch.object(converter.credits_mod, "fetch_model_scopes", return_value={"picker": [], "account": []}) as catalog, \ + patch.object(converter.credits_mod, "fetch_request_usage", return_value={"by_day": {}, "total_credits": 0, "requests": 0}): + self.assertTrue(self.post("sync")["ok"]) + self.assertEqual(catalog.call_count, 1) + self.assertEqual(catalog.call_args.kwargs["uid"], "cn-cli") + + def test_changed_identity_before_file_lock_does_not_refresh_other_account(self): + import contextlib + import json + @contextlib.contextmanager + def changed(*args): + data = json.loads(self.entry["cm"].path.read_text()) + data["account"]["uid"] = "different-owner" + self.entry["cm"].path.write_text(json.dumps(data)) + yield + with patch("app.credential_actions.credential_file_lock", changed): + self.assertFalse(self.post("refresh")["ok"]) + self.assertEqual(self.requests, []) + + def test_batch_sync_has_no_signin_or_trial_side_effects(self): + def balance(token, **kwargs): + return {"credits": 8, "intl": kwargs["uid"].startswith("intl-")} + with patch.object(converter.credits_mod, "fetch_credits", side_effect=balance), \ + patch.object(converter.credits_mod, "fetch_request_usage", return_value={"by_day": {}, "total_credits": 0, "requests": 0}), \ + patch.object(converter.credits_mod, "daily_checkin") as checkin, \ + patch.object(converter, "_sync_trial") as trial: + response = self.client.post("/admin/sync") + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["ok"], response.text) + self.assertEqual(len(response.json()["results"]), 4) + checkin.assert_not_called() + trial.assert_not_called() + + + def test_busy_and_missing_actions_fail_without_upstream_calls(self): + with converter._HOUSEKEEP_LOCK: + self.assertEqual(self.client.post(self.url + "/sync").status_code, 409) + self.assertEqual(self.client.post("/admin/credentials/missing/sync").status_code, 404) + self.assertEqual(self.client.post(self.url + "/invalid").status_code, 404) + self.assertEqual(self.requests, []) + + def test_disabled_account_skipped_including_refresh(self): + Management(converter).admin_set_credential_enabled(self.entry["account_key"], False) + for action in ("checkin", "sync", "refresh"): + self.assertTrue(self.post(action)["skipped"]) + self.assertEqual(self.requests, []) + + def test_batch_checkin_does_not_run_maintenance(self): + with patch.object(converter.credits_mod, "daily_checkin", return_value={"ok": True}) as checkin, \ + patch.object(converter, "_housekeep_once") as full: + response = self.client.post("/admin/checkin") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()["results"]), 4) + self.assertEqual(checkin.call_count, 4) + full.assert_not_called() + + def test_api_and_cookie_auth_require_csrf_for_new_actions(self): + self.client.headers.pop("authorization") + self.assertEqual(self.client.post(self.url + "/refresh").status_code, 401) + session = self.client.post("/admin/session", json={"api_key": "synthetic-management-key"}, + headers={"Origin": "https://testserver"}) + self.assertEqual(session.status_code, 200, session.text) + self.assertEqual(self.client.post(self.url + "/checkin").status_code, 403) + self.client.headers["X-CSRF-Token"] = session.json()["csrf_token"] + self.client.headers["Origin"] = "https://testserver" + with patch.object(converter.credits_mod, "daily_checkin", return_value={"ok": True}): + self.assertTrue(self.post("checkin")["ok"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_credential_automation.py b/tests/test_credential_automation.py new file mode 100644 index 0000000..f7903ab --- /dev/null +++ b/tests/test_credential_automation.py @@ -0,0 +1,218 @@ +"""Per-account automation preferences and runtime gates use only isolated fixtures.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import unittest +from unittest.mock import patch +import converter +from app import checkin, credits, model_policy +from app.control_store import ControlStore +from tests import test_credential_actions as fixtures + + +class AutomationTests(unittest.TestCase): + add_account = fixtures.CredentialActionTests.add_account + configure = fixtures.CredentialActionTests.configure + handle_upstream = fixtures.CredentialActionTests.handle_upstream + setUp = fixtures.CredentialActionTests.setUp + + def preference(self, entry, field, enabled): + response = self.client.patch('/admin/credentials/' + entry['account_key'], json={field: enabled}) + self.assertEqual(response.status_code, 200, response.text) + self.assertIs(response.json()[field], enabled) + + def periodic(self, entry): + with patch.object(credits, 'fetch_credits', return_value={'credits': 10, 'intl': entry['profile'].startswith('intl')}): + return converter._sync_credits(self.pool, self.ledger, entry, checkin=True, failed=set(), claim_trial=False) + + def test_defaults_are_domestic_on_international_off(self): + rows = self.client.get('/admin/credentials').json()['credentials'] + for row in rows: + domestic = row['profile'].startswith('cn-') + self.assertIs(row['auto_checkin'], domestic) + self.assertIs(row['auto_travel'], domestic) + self.assertIs(row['travel_supported'], domestic) + self.assertEqual(row['checkin']['state'], 'unknown') + + def test_settings_persist_without_claiming_and_preserve_account_disable(self): + entry = self.entries['intl-work'] + self.preference(entry, 'enabled', False) + self.preference(entry, 'auto_checkin', True) + reopened = ControlStore(self.root / 'control.sqlite3') + self.addCleanup(reopened.close) + meta = reopened.snapshot()['credentials'][entry['account_key']] + self.assertEqual(meta, {'enabled': False, 'auto_checkin': True}) + self.status_query.assert_not_called() + self.travel_mock.assert_not_called() + self.assertEqual(self.requests, []) + + def test_invalid_values_never_change_revision(self): + before = self.control.snapshot()['revision'] + for data in ({}, {'auto_checkin': 1}, {'auto_travel': 'true'}, {'auto_checkin': None}, + {'enabled': False, 'auto_checkin': True}, {'bad': True}): + with self.subTest(data=data): + self.assertEqual(self.client.patch(self.url, json=data).status_code, 400) + self.assertEqual(self.control.snapshot()['revision'], before) + self.assertEqual(self.client.patch('/admin/credentials/unknown', json={'auto_checkin': True}).status_code, 404) + + def test_international_can_enable_checkin_later_without_enabling_travel(self): + entry = self.entries['intl-work'] + with patch.object(credits, 'daily_checkin', return_value={'ok': True}) as claim: + self.periodic(entry) + self.status_query.assert_not_called() + claim.assert_not_called() + self.preference(entry, 'auto_checkin', True) + self.periodic(entry) + claim.assert_called_once() + self.travel_mock.assert_not_called() + response = self.client.patch('/admin/credentials/' + entry['account_key'], json={'auto_travel': True}) + self.assertEqual(response.status_code, 400) + + def test_separate_switches_preserve_checkin_then_travel_order(self): + events = [] + self.status_query.side_effect = lambda *a, **kw: events.append('status') or {'state': 'available'} + self.travel_mock.side_effect = lambda *a, **kw: events.append('travel') or {'ok': True, 'message': '旅行中'} + with patch.object(credits, 'daily_checkin', side_effect=lambda *a, **kw: events.append('checkin') or {'ok': True}): + self.periodic(self.entry) + self.assertEqual(events, ['status', 'checkin', 'travel']) + events.clear() + self.preference(self.entry, 'auto_checkin', False) + self.periodic(self.entry) + self.assertEqual(events, ['travel']) + events.clear() + self.preference(self.entry, 'auto_travel', False) + self.periodic(self.entry) + self.assertEqual(events, []) + + def test_disabling_during_status_query_cancels_unsent_claim_not_balance(self): + def status(*args, **kwargs): + self.control.set_auto_checkin(self.entry['account_key'], False) + return {'state': 'available'} + self.status_query.side_effect = status + with patch.object(credits, 'daily_checkin') as claim: + self.assertIsNotNone(self.periodic(self.entry)) + claim.assert_not_called() + self.assertEqual(self.ledger.entry(self.entry['id'])['credits']['credits'], 10) + + def test_manual_checkin_is_not_blocked_by_auto_switch_and_can_follow_travel(self): + self.preference(self.entry, 'auto_checkin', False) + with patch.object(credits, 'daily_checkin', return_value={'ok': True}) as claim: + r = self.client.post(self.url + '/checkin') + self.assertTrue(r.json()['results'][0]['ok'], r.text) + claim.assert_called_once() + self.travel_mock.assert_called_once() + self.travel_mock.reset_mock() + self.preference(self.entry, 'auto_travel', False) + self.client.post(self.url + '/checkin') + self.travel_mock.assert_not_called() + + def test_inactive_checkin_is_visible_but_does_not_block_travel(self): + self.status_query.return_value = {'ok': False, 'state': 'inactive', 'code': 10001} + with patch.object(credits, 'daily_checkin') as claim: + response = self.client.post(self.url + '/checkin') + result = response.json()['results'][0] + self.assertFalse(result['ok']) + self.assertIn('未开放', result['message']) + claim.assert_not_called() + self.travel_mock.assert_called_once() + row = next(r for r in self.client.get('/admin/credentials').json()['credentials'] if r['id'] == self.entry['account_key']) + self.assertEqual(row['checkin']['state'], 'inactive') + + def test_sync_does_not_run_automations_and_travel_is_scoped(self): + with patch.object(credits, 'fetch_credits', return_value={'credits': 10, 'intl': False}), \ + patch.object(credits, 'fetch_request_usage', return_value={'by_day': {}, 'total_credits': 0, 'requests': 0}): + self.client.post(self.url + '/sync') + self.status_query.assert_not_called() + self.travel_mock.assert_not_called() + result = self.client.post(self.url + '/travel').json()['results'][0] + self.assertTrue(result['ok']) + self.assertEqual(self.travel_mock.call_args.args[1], 'cn-cli') + self.assertIn('travel', self.ledger.entry(self.entry['id'])) + self.travel_mock.reset_mock() + for action in ('travel', 'travel-status'): + response = self.client.post('/admin/credentials/' + self.entries['intl-work']['account_key'] + '/' + action) + self.assertTrue(response.json()['results'][0]['skipped']) + self.travel_mock.assert_not_called() + + def test_old_lease_during_preflight_stops_both_claim_and_followup(self): + def status(*args, **kwargs): + self.entry['cm'].invalidate() + return {'state': 'available'} + self.status_query.side_effect = status + with patch.object(credits, 'daily_checkin') as claim: + result = self.client.post(self.url + '/checkin').json()['results'][0] + self.assertFalse(result['ok']) + claim.assert_not_called() + self.travel_mock.assert_not_called() + + def test_unexpected_followup_failure_preserves_partial_not_false_success(self): + with patch.object(credits, 'daily_checkin', return_value={'ok': True}): + self.travel_mock.side_effect = OSError('synthetic-secret') + result = self.client.post(self.url + '/checkin').json()['results'][0] + self.assertFalse(result['ok']) + self.assertTrue(result['checkin_ok']) + self.assertNotIn('synthetic-secret', result['message']) + + def test_old_automatic_lease_stops_balance_and_trial_after_preflight(self): + entry = self.entries['intl-work'] + self.preference(entry, 'auto_checkin', True) + def status(*args, **kwargs): + entry['cm'].invalidate() + return {'state': 'available'} + self.status_query.side_effect = status + with patch.object(credits, 'daily_checkin') as claim, patch.object(credits, 'fetch_credits') as balance, \ + patch.object(converter, '_sync_trial') as trial: + failed = set() + self.assertIsNone(converter._sync_credits(self.pool, self.ledger, entry, checkin=True, failed=failed)) + self.assertIn(entry['id'], failed) + claim.assert_not_called() + balance.assert_not_called() + trial.assert_not_called() + + + def test_cookie_preferences_require_csrf(self): + self.client.headers.pop('authorization') + self.assertEqual(self.client.patch(self.url, json={'auto_checkin': False}).status_code, 401) + r = self.client.post('/admin/session', json={'api_key': 'synthetic-management-key'}, headers={'Origin': 'https://testserver'}) + self.assertEqual(r.status_code, 200) + self.assertEqual(self.client.patch(self.url, json={'auto_checkin': False}).status_code, 403) + self.client.headers.update({'Origin': 'https://testserver', 'X-CSRF-Token': r.json()['csrf_token']}) + self.preference(self.entry, 'auto_checkin', False) + + +class CheckinProtocolTests(unittest.TestCase): + def test_status_requires_active_and_explicit_today_flag(self): + cases = [({}, 'unknown'), ({'active': True}, 'unknown'), ({'active': 'true', 'today_checked_in': False}, 'unknown'), + ({'active': False}, 'inactive'), ({'active': True, 'today_checked_in': False}, 'available'), + ({'active': True, 'today_checked_in': True}, 'already')] + for data, expected in cases: + with self.subTest(data=data), patch.object(credits, '_checkin_request', return_value=(200, {'code': 0, 'data': data}, '')): + self.assertEqual(credits.fetch_checkin_status('synthetic')['state'], expected) + + def test_service_failures_are_not_already_checked_in(self): + for status in (0, 401, 403, 404, 429, 500, 503): + with self.subTest(status=status), patch.object(credits, '_checkin_request', return_value=(status, {'code': 1001}, '')): + self.assertFalse(credits.daily_checkin('synthetic')['ok']) + self.assertEqual(credits.fetch_checkin_status('synthetic')['state'], 'unknown') + + def test_business_code_mapping_and_untrusted_messages(self): + for code, message, expected in [(1001, '', 'already'), (1002, '', 'not_eligible'), (1003, '', 'inactive'), + (10001, '活动未开启', 'inactive'), (10001, '今日已签到', 'already'), (10001, 'synthetic-secret', 'error'), + (None, '', 'error'), (False, '', 'error'), (0.1, '', 'error')]: + with self.subTest(code=code, message=message): + r = checkin.normalize(credits.classify_checkin_result(True, code, message)) + self.assertEqual(r['state'], expected) + self.assertNotIn('synthetic-secret', r['message']) + self.assertEqual(checkin.view({'ok': False, 'code': 10001, 'message': '签到活动未开启或已过期'})['state'], 'inactive') + self.assertEqual(checkin.view({'ok': False, 'code': 1001})['state'], 'unknown') + + def test_unknown_status_never_claims(self): + with patch.object(credits, 'fetch_checkin_status', return_value={'state': 'unknown'}), patch.object(credits, 'daily_checkin') as claim: + result = checkin.perform('synthetic') + self.assertFalse(result['ok']) + claim.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_credits.py b/tests/test_credits.py index fee80ce..ebc2517 100644 --- a/tests/test_credits.py +++ b/tests/test_credits.py @@ -86,7 +86,7 @@ def test_financial_hints_rejected_before_network(): def test_financial_profile_hosts_and_web_headers(): - """三类财务请求传递 domain,使用各自品牌 Web 协议而非目录 CLI 身份头。""" + """各品牌不混用,签到走桌面 Bearer 协议,余额和用量保留 Web 协议。""" for domain in ("www.codebuddy.cn", "copilot.tencent.com", "www.workbuddy.cn", "www.codebuddy.ai", "www.workbuddy.ai"): host = "https://" + ("www.codebuddy.cn" if domain == "copilot.tencent.com" else domain) @@ -104,12 +104,15 @@ def test_financial_profile_hosts_and_web_headers(): args, kwargs = client.post.call_args assert args == (host + path,) headers = httpx.Headers(kwargs["headers"]) - assert headers["x-client-platform"] == "web" - assert headers["origin"] == host - assert headers["referer"] == host + "/profile/plans-usage" + if operation is credits.daily_checkin: + assert "x-client-platform" not in headers and "origin" not in headers and "referer" not in headers + else: + assert headers["x-client-platform"] == "web" + assert headers["origin"] == host + assert headers["referer"] == host + "/profile/plans-usage" + assert headers["user-agent"] == credits.BROWSER_UA assert headers["authorization"] == "Bearer opaque" assert headers["x-user-id"] == "test-user" and headers["x-domain"] == domain - assert headers["user-agent"] == credits.BROWSER_UA assert headers["content-type"] == "application/json" assert "x-ide-type" not in headers assert kwargs["timeout"] == credits.REQUEST_TIMEOUT diff --git a/tests/test_dashboard_granularity.py b/tests/test_dashboard_granularity.py new file mode 100644 index 0000000..a7c9b3c --- /dev/null +++ b/tests/test_dashboard_granularity.py @@ -0,0 +1,72 @@ +"""Dashboard bucket regressions using isolated persistent aggregates.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from datetime import datetime, timezone +import tempfile +import unittest +from unittest.mock import patch + +from app.audit_store import AuditStore + + +class GranularityTests(unittest.TestCase): + def setUp(self): + self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) + self.start = datetime(2026, 9, 14, tzinfo=timezone.utc).timestamp() + self.enterContext(patch("app.audit_store.time.time", return_value=self.start + 12.5 * 3600)) + self.store = AuditStore(self.root / "logs.sqlite3") + self.addCleanup(self.store.close) + for hour in (2, 9): + result = self.store.record_request({"id": f"hour-{hour}", **self.store.ticket(), + "started_at": self.start + hour * 3600, "public_model": "synthetic-model", + "profile": "cn-cli", "protocol": "chat", "status_code": 200, "outcome": "success"}) + self.assertTrue(result["ok"]) + + def test_one_day_auto_uses_real_hours_without_changing_summary(self): + hourly = self.store.dashboard(1) + daily = self.store.dashboard(1, "day") + self.assertEqual(hourly["range"]["granularity"], "hour") + self.assertEqual(hourly["range"]["timezone"], "UTC") + self.assertEqual(len(hourly["series"]), 13) + self.assertEqual(len(daily["series"]), 1) + self.assertEqual(hourly["summary"], daily["summary"]) + self.assertEqual([row["bucket"] for row in hourly["series"] if row["requests"]], + [self.start + 2 * 3600, self.start + 9 * 3600]) + self.assertEqual(sum(row["requests"] for row in hourly["series"]), 2) + self.assertIsNone(hourly["series"][0]["credit"]) + self.assertEqual(hourly["series"][0]["requests"], 0) + self.assertLessEqual(hourly["series"][-1]["bucket"], hourly["range"]["end"]) + + def test_granularity_is_explicit_and_bounded(self): + self.assertEqual(self.store.dashboard(7)["range"]["granularity"], "day") + self.assertEqual(self.store.dashboard(7, "hour")["range"]["granularity"], "hour") + self.assertLessEqual(len(self.store.dashboard(90, "hour")["series"]), 90 * 24) + for grain in ("minute", "stats_hourly; DROP TABLE requests", None): + with self.subTest(grain=grain), self.assertRaises(ValueError): + self.store.dashboard(1, grain) + with self.assertRaises(ValueError): + self.store.dashboard(91, "hour") + + def test_detail_clear_and_restart_retain_hourly_stats(self): + before = self.store.dashboard(1) + self.store.clear("details") + reopened = AuditStore(self.root / "logs.sqlite3") + self.addCleanup(reopened.close) + self.assertEqual(reopened.dashboard(1)["series"], before["series"]) + self.assertEqual(reopened.list_records()["items"], []) + reopened.clear("all") + self.assertEqual(sum(row["requests"] for row in reopened.dashboard(1)["series"]), 0) + + def test_missing_hourly_history_is_marked_incomplete_not_split_from_daily(self): + self.store._db.execute("DELETE FROM stats_hourly") + result = self.store.dashboard(1, "hour") + self.assertTrue(result["range"]["partial"]) + self.assertEqual(result["series"], []) + self.assertEqual(result["summary"]["requests"], 2) + self.assertFalse(self.store.dashboard(1, "day")["range"]["partial"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 9a3875c..4f5bd88 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -172,7 +172,10 @@ def test_compose_has_old_syntax_fallbacks_without_required_env_file(self): for filename in ("README.md", "README.zh-CN.md"): doc = (ROOT / filename).read_text() self.assertIn("cp .env.example .env", doc) - self.assertIn("docker compose build", doc) + self.assertIn("docker compose pull", doc) + self.assertIn("docker compose up -d --no-build", doc) + self.assertIn("ghcr.io/maiphucgiang/codebuddy2api:latest", doc) + self.assertNotIn("docker compose build", doc) self.assertNotRegex(doc, r"\bdocker-compose\s") def test_readmes_link_guides_and_webui_setup(self): diff --git a/tests/test_travel.py b/tests/test_travel.py new file mode 100644 index 0000000..169df1b --- /dev/null +++ b/tests/test_travel.py @@ -0,0 +1,158 @@ +"""Travel state transitions, fixed domestic origin, and uncertain write outcomes.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import json +import tempfile +import unittest +from unittest.mock import patch +import httpx +from app import travel +from app.credits import CreditLedger + + +class TravelTests(unittest.TestCase): + def run_trip(self, responses, profile='cn-work', **kwargs): + requests = [] + pending = iter(responses) + def handle(request): + requests.append(request) + value = next(pending) + if isinstance(value, Exception): + raise value + status, body = value if isinstance(value, tuple) else (200, {'code': 0, 'data': value}) + return httpx.Response(status, json=body) + client = httpx.Client(transport=httpx.MockTransport(handle), follow_redirects=False) + with patch('app.travel.httpx.Client', return_value=client), patch('app.travel.random.choice', return_value=2): + result = travel.perform('synthetic-token', profile, **kwargs) + return result, requests + + def test_arrival_claims_rechecks_then_departs(self): + result, calls = self.run_trip([ + {'state': 'arrived', 'daily_limit_reached': False}, {'reward_credit': 8}, + {'state': 'idle', 'daily_limit_reached': False}, {'location': {'id': 2}, 'arrive_at': 300, 'server_now': 100}]) + self.assertTrue(result['ok']) + self.assertTrue(result['claimed']) + self.assertTrue(result['departed']) + self.assertEqual(result['claimed_credit'], 8) + self.assertEqual(result['location_name'], '商场店铺') + self.assertEqual([(c.method, c.url.path) for c in calls], [ + ('GET', travel.PREFIX+'status'), ('POST', travel.PREFIX+'claim'), + ('GET', travel.PREFIX+'status'), ('POST', travel.PREFIX+'depart')]) + for call in calls: + self.assertEqual(call.url.host, 'www.workbuddy.cn') + self.assertEqual(call.headers['authorization'], 'Bearer synthetic-token') + self.assertNotIn('x-device-token', call.headers) + self.assertNotIn('/v2', call.url.path) + self.assertEqual(json.loads(calls[1].content), {}) + self.assertEqual(json.loads(calls[3].content), {'location_id': 2}) + + def test_idle_can_depart_without_checkin(self): + result, calls = self.run_trip([{'state': 'idle', 'daily_limit_reached': False}, {}]) + self.assertTrue(result['departed']) + self.assertFalse(result['claimed']) + self.assertEqual(len(calls), 2) + + def test_traveling_and_limit_do_not_write(self): + for state, limit in [('traveling', False), ('idle', True)]: + with self.subTest(state=state): + result, calls = self.run_trip([{'state': state, 'daily_limit_reached': limit}]) + self.assertTrue(result['ok']) + self.assertTrue(result['skipped']) + self.assertEqual(len(calls), 1) + + def test_arrival_can_claim_even_when_dispatch_limit_reached(self): + result, calls = self.run_trip([{'state': 'arrived', 'daily_limit_reached': True}, {'reward_credit': 8}, + {'state': 'idle', 'daily_limit_reached': True}]) + self.assertTrue(result['claimed']) + self.assertTrue(result['ok']) + self.assertFalse(result['departed']) + self.assertEqual(len(calls), 3) + + def test_read_only_never_claims_even_on_arrival(self): + result, calls = self.run_trip([{'state': 'arrived', 'reward_credit': 7}], read_only=True) + self.assertTrue(result['ok']) + self.assertFalse(result['claimed']) + self.assertEqual(len(calls), 1) + + def test_missing_invalid_and_ambiguous_status_stop_writes(self): + for data in ({}, {'state': None}, {'state': 'unknown'}, {'state': ['idle']}, + {'state': 'idle'}, {'state': 'idle', 'daily_limit_reached': 0}, + {'state': 'idle', 'daily_limit_reached': 'false'}): + with self.subTest(data=data): + result, calls = self.run_trip([data]) + self.assertFalse(result['ok']) + self.assertEqual(len(calls), 1) + + def test_business_failure_is_not_a_success(self): + for body in ({'code': 10001, 'msg': 'synthetic-secret'}, {'code': False, 'data': {'state': 'idle'}}, + {'code': 0}, [], None): + with self.subTest(body=body): + result, calls = self.run_trip([(200, body)]) + self.assertFalse(result['ok']) + self.assertNotIn('synthetic-secret', str(result)) + self.assertEqual(len(calls), 1) + + def test_failed_claim_and_failed_post_claim_query_do_not_depart(self): + for response in [(500, {'code': -1}), httpx.ReadTimeout('synthetic-secret')]: + result, calls = self.run_trip([{'state': 'arrived'}, response]) + self.assertFalse(result['ok']) + self.assertFalse(result['claimed']) + self.assertTrue(result['stale']) + self.assertEqual(len(calls), 2) + self.assertNotIn('synthetic-secret', str(result)) + result, calls = self.run_trip([{'state': 'arrived'}, {'reward_credit': 7}, (503, {})]) + self.assertTrue(result['claimed']) + self.assertFalse(result['ok']) + self.assertFalse(result['departed']) + self.assertEqual(len(calls), 3) + + def test_failed_depart_is_not_replayed_and_preserves_confirmed_claim(self): + result, calls = self.run_trip([{'state': 'arrived'}, {'reward_credit': 8}, + {'state': 'idle', 'daily_limit_reached': False}, httpx.ReadTimeout('uncertain write')]) + self.assertFalse(result['ok']) + self.assertTrue(result['claimed']) + self.assertFalse(result['departed']) + self.assertTrue(result['stale']) + self.assertEqual(len(calls), 4) + + def test_eventually_consistent_arrival_is_not_claimed_twice(self): + result, calls = self.run_trip([{'state': 'arrived'}, {'reward_credit': 8}, {'state': 'arrived'}]) + self.assertFalse(result['ok']) + self.assertTrue(result['claimed']) + self.assertEqual(len(calls), 3) + + def test_setting_change_during_query_stops_claim_or_depart(self): + for state in ('arrived', 'idle'): + decisions = iter([True, False]) + result, calls = self.run_trip([{'state': state, 'daily_limit_reached': False}], can_write=lambda: next(decisions)) + self.assertFalse(result['ok']) + self.assertTrue(result['skipped']) + self.assertEqual(len(calls), 1) + decisions = iter([True, True, False]) + result, calls = self.run_trip([{'state': 'arrived'}, {'reward_credit': 8}, {'state': 'idle', 'daily_limit_reached': False}], + can_write=lambda: next(decisions)) + self.assertTrue(result['claimed']) + self.assertFalse(result['departed']) + self.assertEqual(len(calls), 3) + + def test_international_never_opens_a_network_client(self): + with patch('app.travel.httpx.Client') as client: + for profile in ('intl-work', 'intl-cli', None, 'unknown'): + self.assertTrue(travel.perform('synthetic-token', profile)['skipped']) + client.assert_not_called() + + def test_failure_keeps_last_success_without_recursive_growth(self): + with tempfile.TemporaryDirectory() as folder: + ledger = CreditLedger(Path(folder)/'ledger.json') + travel.remember(ledger, 'one', {'ok': True, 'state': 'traveling', 'message': '旅行中'}) + for _ in range(3): + travel.remember(ledger, 'one', {'ok': False, 'state': 'unknown', 'stale': True, 'message': '查询失败'}) + last = ledger.entry('one')['travel']['last_success'] + self.assertEqual(last['state'], 'traveling') + self.assertNotIn('last_success', last) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_webui_integration.py b/tests/test_webui_integration.py index e97f6e7..88282a5 100644 --- a/tests/test_webui_integration.py +++ b/tests/test_webui_integration.py @@ -8,7 +8,7 @@ import unittest from unittest.mock import patch -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient import converter @@ -51,7 +51,7 @@ def policy(self, source="shared-model", **kwargs): def test_managed_alias_all_protocols_strict_binding_and_response_names(self): identity = self.entries["intl-work"]["account_key"] - self.policy(public_id="garden-fast", region="intl", profile="intl-work", credential_ids=[identity]) + self.policy(public_id="garden-fast", credential_ids=[identity]) published = self.client.get("/v1/models").json()["data"] self.assertIn("garden-fast", [model["id"] for model in published]) self.assertNotIn("shared-model", [model["id"] for model in published]) @@ -66,6 +66,109 @@ def test_managed_alias_all_protocols_strict_binding_and_response_names(self): self.management.admin_set_credential_enabled(identity, False) self.post_rejected("chat/completions", self.payload(selected_model="garden-fast"), (503,)) + def test_custom_models_keep_upstream_and_binding_independent(self): + created = [] + for label, profile in (("studio-cn", "cn-work"), ("studio-intl", "intl-work")): + response = self.client.post("/admin/models", json={ + "revision": self.control.snapshot()["revision"], "public_id": label, + "upstream_id": "shared-model", "credential_ids": [self.entries[profile]["account_key"]]}) + self.assertEqual(response.status_code, 201, response.text) + rule = response.json()["model"] + self.assertTrue(rule["custom"]) + self.assertNotEqual(rule["id"], label) + created.append(rule) + for endpoint in fixtures.GENERATIONS: + for stream in (False, True): + _, wire = self.post_ok(endpoint, self.payload(endpoint, label, stream=stream), {profile}) + self.assertEqual(wire["model"], "shared-model") + published = {row["id"] for row in self.client.get("/v1/models").json()["data"]} + self.assertTrue({"studio-cn", "studio-intl", "shared-model"} <= published) + self.assertTrue(all(row["id"] not in published for row in created)) + self.post_rejected("chat/completions", self.payload(selected_model=created[0]["id"]), (404,)) + bindings = {row["id"]: row["bindings"] for row in self.management.admin_credential_inventory()} + self.assertEqual(bindings[self.entries["cn-work"]["account_key"]], ["studio-cn"]) + self.policy(enabled=False) # 停用目录名称不改变独立自建路由的启停与范围。 + published = {row["id"] for row in self.client.get("/v1/models").json()["data"]} + self.assertNotIn("shared-model", published) + self.assertTrue({"studio-cn", "studio-intl"} <= published) + self.management.admin_set_credential_enabled(self.entries["cn-work"]["account_key"], False) + self.post_rejected("chat/completions", self.payload(selected_model="studio-cn"), (503,)) + self.post_ok("chat/completions", self.payload(selected_model="studio-intl"), {"intl-work"}) + + def test_custom_model_crud_preview_unknown_and_collisions(self): + body = {"revision": 0, "public_id": "my-model", "upstream_id": "shared-model", "region": "cn"} + preview = self.client.post("/admin/models/preview", json=body) + self.assertEqual(preview.status_code, 200, preview.text) + self.assertTrue(preview.json()["candidates"]) + self.assertEqual(self.control.snapshot()["revision"], 0) + created = self.client.post("/admin/models", json=body) + self.assertEqual(created.status_code, 201, created.text) + identifier = created.json()["model"]["id"] + self.assertEqual(self.client.post("/admin/models", json={**body, "revision": 1}).status_code, 400) + conflict = self.client.put("/admin/models/" + identifier, json={**body, "upstream_id": "missing"}) + self.assertEqual(conflict.status_code, 409) + updated = self.client.put("/admin/models/" + identifier, json={**body, "revision": 1, "upstream_id": "cn-cli-only"}) + self.assertEqual(updated.status_code, 200, updated.text) + _, wire = self.post_ok("chat/completions", self.payload(selected_model="my-model"), {"cn-cli"}) + self.assertEqual(wire["model"], "cn-cli-only") + preview = self.client.post("/admin/models/preview", json={"public_id": "unready-model", "upstream_id": "missing"}) + self.assertEqual(preview.status_code, 200, preview.text) + self.assertEqual(preview.json()["candidates"], []) + missing = self.client.post("/admin/models", json={"revision": 2, "public_id": "unready-model", "upstream_id": "missing"}) + self.assertEqual(missing.status_code, 201, missing.text) + self.post_rejected("chat/completions", self.payload(selected_model="unready-model"), (404,)) + self.assertNotIn("unready-model", [row["id"] for row in self.client.get("/v1/models").json()["data"]]) + deleted = self.client.request("DELETE", "/admin/models/" + identifier, json={"revision": 3}) + self.assertEqual(deleted.status_code, 200, deleted.text) + self.assertEqual(len(self.pool.entries()), 4) + self.post_rejected("chat/completions", self.payload(selected_model="my-model"), (404,)) + + def test_custom_auto_keeps_account_scope_through_backend_model_rewrite(self): + tables = fixtures.catalogs() + tables["intl-work"].append(fixtures.model("default-model", "x1")) + self.account_catalogs(tables) + identity = self.entries["intl-work"]["account_key"] + response = self.client.post("/admin/models", json={"revision": 0, "public_id": "my-auto", + "upstream_id": "auto", "credential_ids": [identity]}) + self.assertEqual(response.status_code, 201, response.text) + self.assertIn("my-auto", {row["id"] for row in self.client.get("/v1/models").json()["data"]}) + for endpoint in fixtures.GENERATIONS: + with self.subTest(endpoint=endpoint): + request, wire = self.post_ok(endpoint, self.payload(endpoint, "my-auto", stream=False), {"intl-work"}) + self.assertEqual(wire["model"], "default-model") + self.assertEqual(request.headers["X-Domain"], "www.workbuddy.ai") + self.assertEqual(request.headers["X-User-Id"], "intl-work") + + def test_custom_scope_conflicts_and_policy_changes_fail_closed(self): + from app import model_policy + identity = self.entries["intl-work"]["account_key"] + for scope in ({"region": "intl"}, {"profile": "intl-work"}): + response = self.client.post("/admin/models", json={ + "revision": 0, "public_id": "invalid-scope", "upstream_id": "shared-model", + "credential_ids": [identity], **scope}) + self.assertEqual(response.status_code, 400) + created = self.client.post("/admin/models", json={ + "revision": 0, "public_id": "frozen-policy", "upstream_id": "shared-model"}).json()["model"] + missing_context = model_policy._request_policy.set(None) + try: + with self.assertRaises(HTTPException) as error: + model_policy.resolve(converter.CONFIG, "frozen-policy") + self.assertEqual(error.exception.status_code, 503) + finally: + model_policy._request_policy.reset(missing_context) + token = model_policy._request_policy.set({}) + try: + self.assertEqual(model_policy.resolve(converter.CONFIG, "frozen-policy"), "shared-model") + self.control.update_model(created["id"], {**{k: v for k, v in created.items() if k != "id"}, + "upstream_id": "cn-cli-only"}, 1) + with self.assertRaises(HTTPException) as changed: + model_policy.check_resolved(converter.CONFIG, "shared-model") + self.assertEqual(changed.exception.detail["error"]["code"], "model_policy_changed") + self.assertFalse(model_policy.route_allowed(converter.CONFIG, self.entries["cn-cli"], "shared-model")) + finally: + model_policy._request_policy.reset(token) + + def test_managed_disabled_cannot_bypass_guard_or_alias(self): self.policy(public_id="garden-fast", enabled=False, keep_original=True) converter.CONFIG["model_guard"] = False diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..41801e1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,679 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "codebuddy2api" +version = "1.2.1" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.141.1" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.52.4" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" }, + { url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" }, + { url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" }, + { url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" }, + { url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" }, + { url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" }, + { url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" }, + { url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" }, + { url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" }, + { url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" }, + { url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, + { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, + { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, + { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, + { url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" }, + { url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" }, + { url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" }, + { url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" }, + { url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" }, + { url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" }, + { url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" }, + { url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" }, + { url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, +] diff --git a/web/e2e/automation.spec.ts b/web/e2e/automation.spec.ts new file mode 100644 index 0000000..8017c36 --- /dev/null +++ b/web/e2e/automation.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from "@playwright/test"; + +test("per-account automation persists with scoped actions, dark mode and mobile containment", async ({ + page, +}) => { + const credentials = [ + { + id: "cn", + name: "cn.info", + enabled: true, + profile: "cn-cli", + health: "ready", + auto_checkin: true, + auto_travel: true, + travel_supported: true, + checkin: { state: "already", date: "2026-09-15", message: "今日已签到" }, + travel: { state: "traveling", message: "Buddy 旅行中" }, + }, + { + id: "intl", + name: "intl.info", + enabled: true, + profile: "intl-work", + health: "ready", + auto_checkin: false, + auto_travel: false, + travel_supported: false, + checkin: { state: "inactive", date: "2026-09-15", message: "签到活动未开放或已结束" }, + travel: { state: "unknown", message: "尚未查询" }, + }, + ]; + const writes: string[] = []; + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/admin/**", (route) => { + const req = route.request(); + const path = new URL(req.url()).pathname; + if (path === "/admin/session") + return route.fulfill({ json: { authenticated: true, csrf_token: "fixture-only" } }); + if (path === "/admin/credentials") return route.fulfill({ json: { credentials } }); + if (["PATCH", "POST"].includes(req.method())) { + expect(req.headers()["x-csrf-token"]).toBe("fixture-only"); + writes.push(`${req.method()} ${path}`); + if (req.method() === "PATCH") { + const target = credentials.find((c) => path.endsWith(`/${c.id}`))!; + Object.assign(target, req.postDataJSON()); + return route.fulfill({ + json: { id: target.id, ...req.postDataJSON(), revision: writes.length }, + }); + } + return route.fulfill({ + json: { + results: [ + { + id: "cn", + name: "cn.info", + ok: false, + claimed: true, + message: "领取已确认,后续状态查询失败,未派出", + }, + ], + }, + }); + } + return route.fulfill({ status: 404, json: {} }); + }); + await page.goto("/dashboard/credentials"); + await expect(page.getByRole("switch", { name: "自动签到 cn.info" })).toBeChecked(); + await expect(page.getByRole("switch", { name: "自动旅行 cn.info" })).toBeChecked(); + await expect(page.getByRole("switch", { name: "自动签到 intl.info" })).not.toBeChecked(); + await expect(page.getByRole("switch", { name: "自动旅行 intl.info" })).toBeDisabled(); + await page.getByRole("switch", { name: "自动签到 intl.info" }).click(); + await expect(page.getByText(/保存不会立即领取/)).toBeVisible(); + expect(writes).toEqual(["PATCH /admin/credentials/intl"]); + await page.reload(); + await expect(page.getByRole("switch", { name: "自动签到 intl.info" })).toBeChecked(); + await page.getByRole("switch", { name: "自动旅行 cn.info" }).click(); + await expect(page.getByText(/自动旅行已关闭/)).toBeVisible(); + await page.getByRole("button", { name: "旅行状态 cn.info" }).click(); + await expect(page.getByText("部分完成")).toBeVisible(); + await page.getByRole("button", { name: "旅行领派 cn.info" }).click(); + await expect(page.getByText(/领取已确认,后续状态查询失败/)).toBeVisible(); + expect(writes).toEqual([ + "PATCH /admin/credentials/intl", + "PATCH /admin/credentials/cn", + "POST /admin/credentials/cn/travel-status", + "POST /admin/credentials/cn/travel", + ]); + await page.screenshot({ path: "test-results/automation-desktop.png", fullPage: true }); + await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" }); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await page.getByRole("switch", { name: "自动签到 cn.info" }).scrollIntoViewIfNeeded(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: "test-results/automation-mobile-dark.png", fullPage: true }); + expect(errors).toEqual([]); +}); diff --git a/web/e2e/backend.integration.ts b/web/e2e/backend.integration.ts index 4443843..d1c1c29 100644 --- a/web/e2e/backend.integration.ts +++ b/web/e2e/backend.integration.ts @@ -30,6 +30,25 @@ test("real management API: session, model alias, audit, clear, and file credenti }); expect(generated.status()).toBe(200); expect((await generated.json()).model).toBe("garden-fixture"); + await page.getByRole("button", { name: "新增模型" }).click(); + await page.getByLabel("对外 ID").fill("mapped-fixture"); + await page.getByLabel("上游 ID").fill("fixture-model"); + await page.getByRole("radio", { name: /指定账号/ }).check(); + await page.getByRole("checkbox", { name: /fixture.info/ }).check(); + await page.getByRole("button", { name: "创建模型" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + const mapped = await page.request.post("/v1/chat/completions", { + headers: { Authorization: "Bearer synthetic-e2e-key" }, + data: { + model: "mapped-fixture", + stream: false, + messages: [{ role: "user", content: "synthetic mapping" }], + }, + }); + expect(mapped.status()).toBe(200); + expect((await mapped.json()).model).toBe("mapped-fixture"); + const credentials = (await (await page.request.get("/admin/credentials")).json()).credentials; + expect(credentials[0].bindings).toContain("mapped-fixture"); await page.getByRole("link", { name: "日志审计" }).click(); await expect(page.getByText("garden-fixture", { exact: true }).first()).toBeVisible(); await page.screenshot({ path: "test-results/backend-real-audit.png", fullPage: true }); diff --git a/web/e2e/enhancements.spec.ts b/web/e2e/enhancements.spec.ts new file mode 100644 index 0000000..e47575f --- /dev/null +++ b/web/e2e/enhancements.spec.ts @@ -0,0 +1,141 @@ +import { expect, test, type Page } from "@playwright/test"; + +test.use({ hasTouch: true }); +async function fixture(page: Page) { + const calls: string[] = []; + const credentials = [ + { + id: "one", + name: "one.info", + enabled: true, + profile: "cn-cli", + health: "ready", + credits: { credits: 40 }, + }, + ]; + await page.route("**/admin/**", (route) => { + const path = new URL(route.request().url()).pathname; + if (path === "/admin/session") + return route.fulfill({ json: { authenticated: true, csrf_token: "fixture-only" } }); + if (path === "/admin/credentials") return route.fulfill({ json: { credentials } }); + if (path === "/admin/dashboard") + return route.fulfill({ + json: { + summary: { requests: 17, success_rate: 15 / 17, total_tokens: 500, credit: 1 }, + series: [1, 5, 3, 8].map((requests, i) => ({ + date: `2026-09-14 0${i}:00`, + bucket: 1789344000 + i * 3600, + requests, + success: requests - (i % 2), + error: i % 2, + })), + models: [], + profiles: [], + health: { credentials }, + storage: { degraded: false }, + range: { granularity: "hour" }, + }, + }); + if (route.request().method() === "POST") { + expect(route.request().headers()["x-csrf-token"]).toBe("fixture-only"); + calls.push(path); + return route.fulfill({ + json: { results: [{ id: "one", name: "one.info", ok: true, message: "隔离测试操作成功" }] }, + }); + } + return route.fulfill({ + status: 404, + json: { error: { message: "Unmocked fixture endpoint" } }, + }); + }); + return calls; +} + +test("appearance persists, follows the system, and animates modal exit", async ({ page }) => { + await fixture(page); + await page.emulateMedia({ colorScheme: "light", reducedMotion: "no-preference" }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/dashboard"); + const open = page.getByRole("button", { name: "外观设置" }); + await open.click(); + const sheet = page.getByRole("dialog").locator("section"); + await expect(sheet).toHaveCSS("animation-name", /drawerIn/); + await page.getByLabel("雾蓝").check(); + await expect(page.locator("html")).toHaveAttribute("data-palette", "blue"); + await page.getByLabel("深色", { exact: true }).check(); + const dark = await page + .locator("body") + .evaluate((node) => getComputedStyle(node).backgroundColor); + await expect(page.getByLabel("青叶")).toBeDisabled(); + await page.screenshot({ path: "test-results/appearance-dark.png", fullPage: true }); + await page.getByLabel("浅色", { exact: true }).check(); + await page.getByLabel("蔷薇").check(); + await page.getByLabel("深色", { exact: true }).check(); + await expect(page.locator("body")).toHaveCSS("background-color", dark); + await page.getByLabel("跟随系统").check(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + await page.emulateMedia({ colorScheme: "dark" }); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + const phase = await page + .getByRole("button", { name: "关闭抽屉" }) + .evaluate((node: HTMLButtonElement) => { + node.click(); + return document.documentElement.style.overflow; + }); + expect(phase).toBe("hidden"); + await expect(page.locator("dialog")).toHaveCount(0); + await expect(page.locator("body")).toHaveCSS("position", "static"); + await expect(open).toBeFocused(); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await expect(page.locator("html")).toHaveAttribute("data-palette", "rose"); + await page.emulateMedia({ reducedMotion: "reduce" }); + await open.click(); + await expect(sheet).toHaveCSS("animation-name", "none"); + await page.keyboard.press("Escape"); + await expect(page.locator("dialog")).toHaveCount(0); + expect(errors).toEqual([]); +}); + +test("trend has hover, keyboard and touch values without mobile overflow", async ({ page }) => { + await fixture(page); + await page.goto("/dashboard"); + const chart = page.getByRole("img", { name: "按小时请求量趋势" }); + let box = (await chart.boundingBox())!; + await page.mouse.move(box.x + box.width * 0.97, box.y + box.height * 0.5); + await expect(page.getByRole("tooltip")).toContainText("03:00"); + await chart.focus(); + await page.keyboard.press("ArrowRight"); + await expect(page.getByRole("tooltip")).toContainText("01:00"); + await page.keyboard.press("End"); + await expect(page.getByRole("tooltip")).toContainText("03:00"); + await page.setViewportSize({ width: 390, height: 844 }); + box = (await chart.boundingBox())!; + await page.touchscreen.tap(box.x + box.width * 0.65, box.y + box.height * 0.6); + await expect(page.getByRole("tooltip")).toContainText("02:00"); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: "test-results/trend-mobile.png", fullPage: true }); +}); + +test("credential refresh, checkin, sync and batch buttons stay separate", async ({ page }) => { + const calls = await fixture(page); + await page.goto("/dashboard/credentials"); + for (const label of [ + "刷新凭证 one.info", + "签到 one.info", + "同步余额 one.info", + "批量签到", + "同步全部余额", + ]) { + await page.getByRole("button", { name: label, exact: true }).click(); + await expect(page.getByText("隔离测试操作成功")).toBeVisible(); + } + expect(calls).toEqual([ + "/admin/credentials/one/refresh", + "/admin/credentials/one/checkin", + "/admin/credentials/one/sync", + "/admin/checkin", + "/admin/sync", + ]); +}); diff --git a/web/e2e/experience.spec.ts b/web/e2e/experience.spec.ts new file mode 100644 index 0000000..127d43f --- /dev/null +++ b/web/e2e/experience.spec.ts @@ -0,0 +1,255 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function experienceAPI(page: Page) { + let revision = 1, + enrolled = false; + const calls: { method: string; path: string; body: Record | null }[] = []; + const credentials = [ + { + id: "cn-account", + name: "mainland.info", + profile: "cn-cli", + enabled: true, + health: "ready", + nickname: "隔离测试 · 大陆", + credits: { remaining: 200 }, + cooldowns: [], + }, + { + id: "intl-account", + name: "international.info", + profile: "intl-work", + enabled: true, + health: "ready", + nickname: "隔离测试 · 国际", + credits: { remaining: 120 }, + cooldowns: [], + }, + ]; + const models: Record[] = Array.from({ length: 48 }, (_, i) => ({ + id: `fixture-model-${i}`, + public_id: `fixture-model-${i}`, + upstream_id: `fixture-model-${i}`, + enabled: true, + keep_original: false, + custom: false, + region: null, + profile: null, + credential_ids: [], + available: true, + credits: 0, + credits_by_profile: { "cn-cli": 0, "intl-work": 0.5 }, + })); + await page.route("**/admin/**", async (route) => { + const request = route.request(), + url = new URL(request.url()), + method = request.method(); + const path = decodeURIComponent(url.pathname); + const body = request.postData() ? (request.postDataJSON() as Record) : null; + calls.push({ method, path: path + url.search, body }); + const send = (json: unknown, status = 200) => route.fulfill({ json, status }); + if (path === "/admin/session") + return send({ authenticated: true, csrf_token: "isolated-csrf" }); + if (method !== "GET" || path.endsWith("/poll")) + expect(request.headers()["x-csrf-token"]).toBe("isolated-csrf"); + if (path === "/admin/credentials") + return send({ + credentials: enrolled + ? [...credentials, { ...credentials[1], id: "new-account", name: "newly-enrolled.info" }] + : credentials, + }); + if (path === "/admin/oauth/start") + return send({ + login_id: "isolated", + verification_uri: "https://www.codebuddy.cn/isolated-oauth", + expires_in: 60, + }); + if (path === "/admin/oauth/poll") { + enrolled = true; + return send({ done: true }); + } + if (path.endsWith("/preview")) { + const ids = body?.credential_ids as string[]; + const allowed = credentials.filter( + (c) => + (!ids.length || ids.includes(c.id)) && + (!body?.region || (typeof body.region === "string" && c.profile.startsWith(body.region))), + ); + return send({ + candidates: allowed, + excluded: credentials + .filter((c) => !allowed.includes(c)) + .map((c) => ({ ...c, reason: "不在绑定范围" })), + }); + } + if (path === "/admin/models") { + if (method === "POST") { + expect(body?.revision).toBe(revision); + const created = { ...body, id: "custom:isolated", custom: true, available: true }; + models.push(created); + revision++; + return send({ revision, model: created }, 201); + } + return send({ revision, models }); + } + if (path === "/admin/models/custom:isolated" && method === "DELETE") { + expect(body?.revision).toBe(revision); + models.splice( + models.findIndex((m) => m.id === "custom:isolated"), + 1, + ); + revision++; + return send({ revision, ok: true }); + } + if (path === "/admin/dashboard") { + const days = Number(url.searchParams.get("days") ?? 7); + const grain = url.searchParams.get("granularity"); + const hourly = grain === "hour" || (grain === "auto" && days === 1); + const endDay = Date.UTC(2026, 8, 14) / 1000, + start = endDay - (days - 1) * 86400; + const hours: Record = { 9: 8, 12: 17, 16: 11, 19: 4, 22: 6 }; + const series = Array.from({ length: days * (hourly ? 24 : 1) }, (_, i) => { + const bucket = start + i * (hourly ? 3600 : 86400); + const count = bucket < endDay ? 0 : hourly ? (hours[(bucket - endDay) / 3600] ?? 0) : 46; + return { + bucket, + date: new Date(bucket * 1000) + .toISOString() + .slice(0, hourly ? 16 : 10) + .replace("T", " "), + requests: count, + success: count, + error: 0, + }; + }); + return send({ + summary: { requests: 46, success_rate: 1, total_tokens: 8400, credit: 0 }, + series, + models: [{ model: "fixture-model-0", requests: 46, total_tokens: 8400, credit: 0 }], + profiles: [ + { profile: "cn-cli", requests: 23 }, + { profile: "intl-work", requests: 23 }, + ], + health: { credentials }, + storage: { + degraded: false, + logical_bytes: 4096, + max_bytes: 1048576, + db_bytes: 32768, + retention_days: 30, + pending_cleanup: false, + }, + range: { days, granularity: hourly ? "hour" : "day", timezone: "UTC", partial: false }, + generated_at: endDay + 23 * 3600, + }); + } + return send({ error: { message: `Unmocked ${method} ${path}` } }, 404); + }); + return calls; +} + +test("glass workspace folds, creates a scoped model and keeps dialogs isolated", async ({ + page, +}) => { + const calls = await experienceAPI(page); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/dashboard/models"); + const opener = page.getByRole("button", { name: "编辑规则" }).nth(24); + await opener.scrollIntoViewIfNeeded(); + const before = await page.evaluate(() => scrollY); + await opener.click(); + await expect(page.locator("body")).toHaveCSS("position", "fixed"); + await expect(page.locator("html")).toHaveCSS("overflow", "hidden"); + await expect(page.locator("body")).toHaveCSS("top", `${-before}px`); + await page.mouse.move(100, 400); + await page.mouse.wheel(0, 600); + await expect(page.locator("body")).toHaveCSS("top", `${-before}px`); + await page.getByRole("dialog").click({ position: { x: 100, y: 400 } }); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect(await page.evaluate(() => scrollY)).toBe(before); + await expect(opener).toBeFocused(); + await page.getByRole("button", { name: "收起侧栏" }).click(); + await expect(page.locator("aside")).toHaveCSS("width", "76px"); + await page.reload(); + await expect(page.getByRole("button", { name: "展开侧栏" })).toBeVisible(); + await page.getByRole("button", { name: "新增模型" }).click(); + await page.getByLabel("对外 ID").fill("team-coding"); + await page.getByLabel("上游 ID").fill("fixture-model-0"); + await page.getByRole("radio", { name: /指定区域/ }).check(); + await page.getByRole("combobox", { name: "区域", exact: true }).selectOption("intl"); + await page.getByRole("radio", { name: /指定账号/ }).check(); + await expect(page.getByRole("combobox", { name: "区域", exact: true })).toHaveCount(0); + await page.getByRole("checkbox", { name: /international.info/ }).check(); + await page.getByRole("button", { name: "预览候选路由" }).click(); + await expect(page.getByRole("heading", { name: "路由预览" })).toBeVisible(); + await page.screenshot({ path: "test-results/glass-model-editor.png" }); + await page.getByRole("button", { name: "创建模型" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + const created = calls.find((call) => call.path === "/admin/models" && call.method === "POST")!; + expect(created.body).toMatchObject({ + public_id: "team-coding", + upstream_id: "fixture-model-0", + credential_ids: ["intl-account"], + region: null, + profile: null, + }); + await page.getByLabel("搜索模型").fill("team-coding"); + await expect(page.getByText("team-coding", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "删除", exact: true }).click(); + await page.getByRole("button", { name: "确认删除模型" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("没有匹配的模型")).toBeVisible(); + expect(errors).toEqual([]); +}); + +test("daily range uses hourly points, with desktop and mobile glass layouts", async ({ page }) => { + await experienceAPI(page); + await page.goto("/dashboard"); + await page.getByLabel("统计时间范围").selectOption("1"); + await expect(page.getByRole("img", { name: "按小时请求量趋势" }).locator("circle")).toHaveCount( + 24, + ); + await page.getByRole("button", { name: "收起侧栏" }).click(); + await expect(page.locator("aside")).toHaveCSS("width", "76px"); + await page.screenshot({ path: "test-results/glass-overview-hourly.png" }); + await page.getByLabel("统计粒度").selectOption("day"); + await expect(page.getByRole("img", { name: "按日请求量趋势" }).locator("circle")).toHaveCount(1); + await page.getByLabel("统计粒度").selectOption("hour"); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByRole("img", { name: "按小时请求量趋势" })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: "test-results/glass-overview-mobile.png" }); + await page.getByRole("link", { name: "模型路由", exact: true }).click(); + await page.getByRole("button", { name: "新增模型" }).click(); + await page.getByRole("radio", { name: /指定账号/ }).check(); + const dialog = page.getByRole("dialog"); + const bounds = await dialog.boundingBox(); + expect(bounds!.width).toBeLessThanOrEqual(390); + await expect(page.locator("body")).toHaveCSS("position", "fixed"); + await page.screenshot({ path: "test-results/glass-editor-mobile.png" }); + await dialog.click({ position: { x: 2, y: 2 } }); + await expect(dialog).toHaveCount(0); +}); + +test("OAuth enrollment closes the drawer and refreshes without exposing the opener", async ({ + page, + context, +}) => { + const calls = await experienceAPI(page); + await context.route("https://www.codebuddy.cn/**", (route) => + route.fulfill({ contentType: "text/html", body: "

Isolated authorization fixture

" }), + ); + await page.goto("/dashboard/credentials"); + await page.getByRole("button", { name: "添加凭证" }).click(); + await page.getByRole("button", { name: "发起 OAuth 授权" }).click(); + const opened = page.waitForEvent("popup"); + await page.getByRole("link", { name: /打开官方授权页面/ }).click(); + const popup = await opened; + await popup.waitForURL("https://www.codebuddy.cn/isolated-oauth"); + expect(await popup.evaluate(() => window.opener === null)).toBe(true); + await expect(page.getByRole("dialog")).toHaveCount(0, { timeout: 8000 }); + await expect(page.getByText("newly-enrolled.info", { exact: true })).toBeVisible(); + expect(popup.isClosed()).toBe(false); // The external tab remains isolated, not controlled by the admin page. + expect(calls.filter((call) => call.path === "/admin/credentials")).toHaveLength(2); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index b7cbe34..d0fd1c3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import { Models } from "./pages/Models"; import { Credentials } from "./pages/Credentials"; import { Logs } from "./pages/Logs"; import { Settings } from "./pages/Settings"; +import { Appearance } from "./appearance"; import s from "./ui.module.scss"; const navigation = [ @@ -42,32 +43,65 @@ function Shell() { const location = useLocation(); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem("codebuddy.sidebar.collapsed") === "true"; + } catch { + return false; + } + }); + const toggleSidebar = () => { + const next = !collapsed; + setCollapsed(next); + try { + localStorage.setItem("codebuddy.sidebar.collapsed", String(next)); + } catch { + /* Session-only preference when storage is unavailable. */ + } + }; return ( -
+
跳转到内容